Cost control
This commit is contained in:
@@ -28,6 +28,7 @@ class OpenRouterClient:
|
||||
)
|
||||
|
||||
self.model = config.ai.model
|
||||
self.total_cost = 0.0 # Track cumulative cost for this session
|
||||
logger.debug(f"Initialized OpenRouter client with model: {self.model}")
|
||||
|
||||
async def chat_completion(
|
||||
@@ -73,7 +74,7 @@ class OpenRouterClient:
|
||||
if not content:
|
||||
raise ValueError("Empty response from API")
|
||||
|
||||
# Log token usage
|
||||
# Track cost from OpenRouter response
|
||||
if response.usage:
|
||||
logger.debug(
|
||||
f"Tokens used - Prompt: {response.usage.prompt_tokens}, "
|
||||
@@ -81,6 +82,15 @@ class OpenRouterClient:
|
||||
f"Total: {response.usage.total_tokens}"
|
||||
)
|
||||
|
||||
# OpenRouter returns cost in credits (1 credit = $1)
|
||||
# The usage object has a 'cost' field in newer responses
|
||||
if hasattr(response.usage, "cost") and response.usage.cost is not None:
|
||||
cost = float(response.usage.cost)
|
||||
self.total_cost += cost
|
||||
logger.debug(
|
||||
f"Request cost: ${cost:.6f}, Session total: ${self.total_cost:.6f}"
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
except Exception as e:
|
||||
@@ -115,3 +125,11 @@ class OpenRouterClient:
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse JSON response: {content}")
|
||||
raise ValueError(f"Invalid JSON response: {e}")
|
||||
|
||||
def get_session_cost(self) -> float:
|
||||
"""Get total cost accumulated during this session"""
|
||||
return self.total_cost
|
||||
|
||||
def reset_session_cost(self):
|
||||
"""Reset session cost counter"""
|
||||
self.total_cost = 0.0
|
||||
|
||||
@@ -22,7 +22,12 @@ class EmailGenerator:
|
||||
self.env = Environment(loader=FileSystemLoader(template_dir))
|
||||
|
||||
def generate_digest_email(
|
||||
self, entries: list[DigestEntry], date_str: str, subject: str
|
||||
self,
|
||||
entries: list[DigestEntry],
|
||||
date_str: str,
|
||||
subject: str,
|
||||
session_cost: float = 0.0,
|
||||
cumulative_cost: float = 0.0,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Generate HTML email for daily digest
|
||||
@@ -31,6 +36,8 @@ class EmailGenerator:
|
||||
entries: List of digest entries (articles with summaries)
|
||||
date_str: Date string for the digest
|
||||
subject: Email subject line
|
||||
session_cost: Cost for this digest generation
|
||||
cumulative_cost: Total cost across all runs
|
||||
|
||||
Returns:
|
||||
Tuple of (html_content, text_content)
|
||||
@@ -54,6 +61,8 @@ class EmailGenerator:
|
||||
"total_sources": unique_sources,
|
||||
"total_categories": len(sorted_categories),
|
||||
"articles_by_category": {cat: articles_by_category[cat] for cat in sorted_categories},
|
||||
"session_cost": session_cost,
|
||||
"cumulative_cost": cumulative_cost,
|
||||
}
|
||||
|
||||
# Render HTML template
|
||||
@@ -64,14 +73,21 @@ class EmailGenerator:
|
||||
html_inlined = transform(html)
|
||||
|
||||
# Generate plain text version
|
||||
text = self._generate_text_version(entries, date_str, subject)
|
||||
text = self._generate_text_version(
|
||||
entries, date_str, subject, session_cost, cumulative_cost
|
||||
)
|
||||
|
||||
logger.debug(f"Generated email with {len(entries)} articles")
|
||||
|
||||
return html_inlined, text
|
||||
|
||||
def _generate_text_version(
|
||||
self, entries: list[DigestEntry], date_str: str, subject: str
|
||||
self,
|
||||
entries: list[DigestEntry],
|
||||
date_str: str,
|
||||
subject: str,
|
||||
session_cost: float = 0.0,
|
||||
cumulative_cost: float = 0.0,
|
||||
) -> str:
|
||||
"""Generate plain text version of email"""
|
||||
lines = [
|
||||
@@ -110,5 +126,9 @@ class EmailGenerator:
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("Generated by News Agent | Powered by OpenRouter AI")
|
||||
lines.append("")
|
||||
lines.append("COST INFORMATION")
|
||||
lines.append(f"This digest: ${session_cost:.4f}")
|
||||
lines.append(f"Total spent: ${cumulative_cost:.4f}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -188,6 +188,15 @@
|
||||
<div class="footer">
|
||||
<p>Generated by News Agent | Powered by OpenRouter AI</p>
|
||||
<p>You received this because you subscribed to daily tech news digests</p>
|
||||
<hr style="margin: 20px 0; border: none; border-top: 1px solid #e5e7eb;">
|
||||
<div style="background-color: #f9fafb; padding: 15px; border-radius: 6px; margin-top: 20px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #374151;">💰 Cost Information</p>
|
||||
<p style="margin: 5px 0 0 0; font-size: 14px;">
|
||||
<span style="color: #059669;">This digest: ${{ "%.4f"|format(session_cost) }}</span>
|
||||
|
|
||||
<span style="color: #2563eb;">Total spent: ${{ "%.4f"|format(cumulative_cost) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
28
src/main.py
28
src/main.py
@@ -75,6 +75,15 @@ async def main():
|
||||
await db.update_article_processing(
|
||||
article.id, relevance_score=0.0, ai_summary="", included=False
|
||||
)
|
||||
|
||||
# Still save the run with costs (for filtering only)
|
||||
session_cost = ai_client.get_session_cost()
|
||||
await db.save_run(
|
||||
articles_fetched=len(articles),
|
||||
articles_processed=len(unprocessed),
|
||||
articles_included=0,
|
||||
total_cost=session_cost,
|
||||
)
|
||||
return
|
||||
|
||||
# Summarize filtered articles (using batch processing for speed, silently)
|
||||
@@ -109,14 +118,29 @@ async def main():
|
||||
article.id, relevance_score=0.0, ai_summary="", included=False
|
||||
)
|
||||
|
||||
# Generate email (silently)
|
||||
# Get cost information
|
||||
session_cost = ai_client.get_session_cost()
|
||||
total_cost = await db.get_total_cost()
|
||||
cumulative_cost = total_cost + session_cost
|
||||
|
||||
# Save run statistics with costs
|
||||
await db.save_run(
|
||||
articles_fetched=len(articles),
|
||||
articles_processed=len(unprocessed),
|
||||
articles_included=len(digest_entries),
|
||||
total_cost=session_cost,
|
||||
filtering_cost=0.0, # Could split this if tracking separately
|
||||
summarization_cost=0.0,
|
||||
)
|
||||
|
||||
# Generate email (silently) with cost info
|
||||
generator = EmailGenerator()
|
||||
|
||||
date_str = datetime.now().strftime("%A, %B %d, %Y")
|
||||
subject = config.email.subject_template.format(date=date_str)
|
||||
|
||||
html_content, text_content = generator.generate_digest_email(
|
||||
digest_entries, date_str, subject
|
||||
digest_entries, date_str, subject, session_cost, cumulative_cost
|
||||
)
|
||||
|
||||
# Send email (silently)
|
||||
|
||||
@@ -56,6 +56,30 @@ class Database:
|
||||
"""
|
||||
)
|
||||
|
||||
# Create runs table for tracking costs
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_date TEXT NOT NULL,
|
||||
articles_fetched INTEGER NOT NULL,
|
||||
articles_processed INTEGER NOT NULL,
|
||||
articles_included INTEGER NOT NULL,
|
||||
total_cost REAL NOT NULL,
|
||||
filtering_cost REAL DEFAULT 0,
|
||||
summarization_cost REAL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_run_date
|
||||
ON runs(run_date)
|
||||
"""
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.debug(f"Database initialized at {self.db_path}")
|
||||
@@ -175,6 +199,72 @@ class Database:
|
||||
if deleted > 0:
|
||||
logger.debug(f"Cleaned up {deleted} old articles")
|
||||
|
||||
async def save_run(
|
||||
self,
|
||||
articles_fetched: int,
|
||||
articles_processed: int,
|
||||
articles_included: int,
|
||||
total_cost: float,
|
||||
filtering_cost: float = 0.0,
|
||||
summarization_cost: float = 0.0,
|
||||
) -> int:
|
||||
"""
|
||||
Save run statistics including costs
|
||||
|
||||
Returns:
|
||||
Run ID
|
||||
"""
|
||||
run_date = datetime.now().date().isoformat()
|
||||
created_at = datetime.now().isoformat()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
INSERT INTO runs (
|
||||
run_date, articles_fetched, articles_processed,
|
||||
articles_included, total_cost, filtering_cost,
|
||||
summarization_cost, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
run_date,
|
||||
articles_fetched,
|
||||
articles_processed,
|
||||
articles_included,
|
||||
total_cost,
|
||||
filtering_cost,
|
||||
summarization_cost,
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
run_id = cursor.lastrowid
|
||||
await db.commit()
|
||||
|
||||
logger.debug(f"Saved run {run_id}: ${total_cost:.4f}")
|
||||
return run_id
|
||||
|
||||
async def get_total_cost(self) -> float:
|
||||
"""Get cumulative total cost across all runs"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute("SELECT SUM(total_cost) FROM runs") as cursor:
|
||||
result = await cursor.fetchone()
|
||||
return result[0] if result[0] is not None else 0.0
|
||||
|
||||
async def get_run_stats(self, limit: int = 10) -> list[dict]:
|
||||
"""Get recent run statistics"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"""
|
||||
SELECT * FROM runs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def _row_to_article(self, row: aiosqlite.Row) -> Article:
|
||||
"""Convert database row to Article model"""
|
||||
return Article(
|
||||
|
||||
60
src/view_costs.py
Normal file
60
src/view_costs.py
Normal file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""View cost statistics from the database"""
|
||||
|
||||
import asyncio
|
||||
from .config import get_config
|
||||
from .storage.database import Database
|
||||
|
||||
|
||||
async def main():
|
||||
"""Display cost statistics"""
|
||||
config = get_config()
|
||||
db = Database(config.database.path)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("News Agent Cost Statistics")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
# Get total cost
|
||||
total_cost = await db.get_total_cost()
|
||||
print(f"💰 Total Cumulative Cost: ${total_cost:.4f}")
|
||||
print()
|
||||
|
||||
# Get recent runs
|
||||
runs = await db.get_run_stats(limit=20)
|
||||
|
||||
if not runs:
|
||||
print("No runs recorded yet.")
|
||||
return
|
||||
|
||||
print(f"Recent Runs (last {len(runs)}):")
|
||||
print("-" * 60)
|
||||
print(f"{'Date':<12} {'Articles':<10} {'Included':<10} {'Cost':<10}")
|
||||
print("-" * 60)
|
||||
|
||||
for run in runs:
|
||||
date = run["run_date"]
|
||||
articles = run["articles_processed"]
|
||||
included = run["articles_included"]
|
||||
cost = run["total_cost"]
|
||||
|
||||
print(f"{date:<12} {articles:<10} {included:<10} ${cost:<9.4f}")
|
||||
|
||||
print("-" * 60)
|
||||
|
||||
# Calculate averages
|
||||
if runs:
|
||||
avg_cost = sum(r["total_cost"] for r in runs) / len(runs)
|
||||
avg_articles = sum(r["articles_included"] for r in runs) / len(runs)
|
||||
|
||||
print(f"\nAverages (last {len(runs)} runs):")
|
||||
print(f" Cost per run: ${avg_cost:.4f}")
|
||||
print(f" Articles per digest: {avg_articles:.1f}")
|
||||
if avg_articles > 0:
|
||||
print(f" Cost per article: ${avg_cost / avg_articles:.4f}")
|
||||
|
||||
print("\n" + "=" * 60 + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user