| import json |
| import os |
| from datetime import datetime, timezone |
| from pathlib import Path |
| from typing import Optional |
|
|
| COST_LOG_PATH = Path("data/cost_log.jsonl") |
|
|
| |
| |
| |
| _PRICING = { |
| "claude-opus-4-8": {"input": 5.0 / 1_000_000, "output": 25.0 / 1_000_000}, |
| "claude-sonnet-4-6": {"input": 3.0 / 1_000_000, "output": 15.0 / 1_000_000}, |
| "claude-haiku-4-5-20251001": {"input": 1.0 / 1_000_000, "output": 5.0 / 1_000_000}, |
| "gpt-5.1": {"input": 1.25 / 1_000_000, "output": 10.0 / 1_000_000}, |
| "gpt-5-mini": {"input": 0.25 / 1_000_000, "output": 2.0 / 1_000_000}, |
| } |
| _DEFAULT_PRICING = {"input": 3.0 / 1_000_000, "output": 15.0 / 1_000_000} |
|
|
|
|
| def log_usage(ticker: str, model: str, input_tokens: int, output_tokens: int) -> None: |
| """Append one usage record to data/cost_log.jsonl.""" |
| COST_LOG_PATH.parent.mkdir(parents=True, exist_ok=True) |
| pricing = _PRICING.get(model, _DEFAULT_PRICING) |
| cost_usd = input_tokens * pricing["input"] + output_tokens * pricing["output"] |
| record = { |
| "ts": datetime.now(timezone.utc).isoformat(), |
| "ticker": ticker, |
| "model": model, |
| "input_tokens": input_tokens, |
| "output_tokens": output_tokens, |
| "cost_usd": round(cost_usd, 6), |
| } |
| with COST_LOG_PATH.open("a", encoding="utf-8") as f: |
| f.write(json.dumps(record) + "\n") |
|
|
|
|
| def _message_usage(msg) -> tuple[int, int]: |
| """Extract (input_tokens, output_tokens) from one AI message. |
| |
| Tries three shapes, in order, and stops at the first that yields tokens |
| (never double-counts a message across shapes): |
| 1. `usage_metadata` — LangChain's provider-normalized field, present on |
| both langchain-anthropic and langchain-openai AIMessages. |
| 2. Anthropic legacy: `response_metadata['usage']`. |
| 3. OpenAI legacy: `response_metadata['token_usage']` (prompt/completion). |
| """ |
| usage_metadata = getattr(msg, "usage_metadata", None) or {} |
| if usage_metadata.get("input_tokens") or usage_metadata.get("output_tokens"): |
| return usage_metadata.get("input_tokens", 0), usage_metadata.get("output_tokens", 0) |
|
|
| meta = getattr(msg, "response_metadata", {}) or {} |
|
|
| anthropic_usage = meta.get("usage") or {} |
| if anthropic_usage.get("input_tokens") or anthropic_usage.get("output_tokens"): |
| return anthropic_usage.get("input_tokens", 0), anthropic_usage.get("output_tokens", 0) |
|
|
| openai_usage = meta.get("token_usage") or {} |
| if openai_usage.get("prompt_tokens") or openai_usage.get("completion_tokens"): |
| return openai_usage.get("prompt_tokens", 0), openai_usage.get("completion_tokens", 0) |
|
|
| return 0, 0 |
|
|
|
|
| def compute_run_cost(messages: list, model: str) -> dict: |
| """Extract usage from LangChain AI messages and return total cost summary.""" |
| total_input = 0 |
| total_output = 0 |
| for msg in messages: |
| in_tok, out_tok = _message_usage(msg) |
| total_input += in_tok |
| total_output += out_tok |
|
|
| pricing = _PRICING.get(model, _DEFAULT_PRICING) |
| cost_usd = total_input * pricing["input"] + total_output * pricing["output"] |
| return { |
| "input_tokens": total_input, |
| "output_tokens": total_output, |
| "cost_usd": round(cost_usd, 6), |
| } |
|
|
|
|
| def average_brief_cost_usd() -> Optional[float]: |
| """Return average cost_usd per brief run from cost_log.jsonl, or None if log is empty. |
| |
| Used by the Portfolio screen to show an estimated cost before bulk generation. |
| """ |
| if not COST_LOG_PATH.exists(): |
| return None |
| costs: list[float] = [] |
| try: |
| with COST_LOG_PATH.open("r", encoding="utf-8") as f: |
| for line in f: |
| line = line.strip() |
| if not line: |
| continue |
| record = json.loads(line) |
| c = record.get("cost_usd") |
| if c is not None: |
| costs.append(float(c)) |
| except Exception: |
| return None |
| return sum(costs) / len(costs) if costs else None |
|
|