From de3c42042b72bc45f58766b99171a6e5546eb6bc Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:49:51 -0700 Subject: [PATCH] feat(usage): show official API list cost on Token Usage Token Usage already records input, output, cache writes, and cache reads. Price those buckets from the models.dev catalog Codeg already fetches, and put a muted estimate under the hero total plus an optional per-model hint. --- src-tauri/src/acp/opencode_catalog.rs | 51 ++- .../token-usage/token-usage-page.tsx | 78 ++++- src/i18n/messages/ar.json | 5 +- src/i18n/messages/de.json | 5 +- src/i18n/messages/en.json | 5 +- src/i18n/messages/es.json | 5 +- src/i18n/messages/fr.json | 5 +- src/i18n/messages/ja.json | 5 +- src/i18n/messages/ko.json | 5 +- src/i18n/messages/pt.json | 5 +- src/i18n/messages/zh-CN.json | 5 +- src/i18n/messages/zh-TW.json | 5 +- src/lib/model-api-rates.test.ts | 269 ++++++++++++++++ src/lib/model-api-rates.ts | 298 ++++++++++++++++++ src/lib/opencode-connect.test.ts | 4 + src/lib/token-usage.test.ts | 14 + src/lib/token-usage.ts | 17 + src/lib/types.ts | 2 + 18 files changed, 760 insertions(+), 23 deletions(-) create mode 100644 src/lib/model-api-rates.test.ts create mode 100644 src/lib/model-api-rates.ts diff --git a/src-tauri/src/acp/opencode_catalog.rs b/src-tauri/src/acp/opencode_catalog.rs index 81b5bbed2..b3a65c0b8 100644 --- a/src-tauri/src/acp/opencode_catalog.rs +++ b/src-tauri/src/acp/opencode_catalog.rs @@ -53,6 +53,10 @@ pub struct CatalogModel { pub cost_in: Option, #[serde(default)] pub cost_out: Option, + #[serde(default)] + pub cost_cache_read: Option, + #[serde(default)] + pub cost_cache_write: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -149,6 +153,14 @@ pub fn normalize_models_dev(raw: &str) -> Result, AppComman .get("cost") .and_then(|v| v.get("output")) .and_then(|v| v.as_f64()), + cost_cache_read: m + .get("cost") + .and_then(|v| v.get("cache_read")) + .and_then(|v| v.as_f64()), + cost_cache_write: m + .get("cost") + .and_then(|v| v.get("cache_write")) + .and_then(|v| v.as_f64()), }); } } @@ -181,7 +193,13 @@ pub fn bundled_catalog() -> Vec { } fn cache_path(data_dir: &Path) -> PathBuf { - data_dir.join("cache").join("opencode").join("models-dev.json") + // v2: cache_read / cache_write joined the slim shape. A new filename so + // an existing 24h cache is not served without those fields until it ages + // out, which would leave cache tokens unpriced on Token Usage. + data_dir + .join("cache") + .join("opencode") + .join("models-dev-v2.json") } fn read_cache(data_dir: &Path, require_fresh: bool) -> Option> { @@ -303,7 +321,17 @@ mod tests { "reasoning": true, "tool_call": true, "limit": { "context": 128000, "output": 8192 }, - "cost": { "input": 1.5, "output": 6.0 } + "cost": { + "input": 1.5, + "output": 6.0, + "cache_read": 0.15, + "cache_write": 1.875 + } + }, + "demo-free": { + "id": "demo-free", + "name": "Demo Free", + "cost": { "input": 0.0, "output": 0.0 } } } } @@ -316,14 +344,27 @@ mod tests { assert_eq!(p.npm.as_deref(), Some("@ai-sdk/openai-compatible")); assert_eq!(p.env, vec!["DEMO_API_KEY".to_string()]); assert_eq!(p.auth_kind, "api"); - assert_eq!(p.models.len(), 1); - let m = &p.models[0]; - assert_eq!(m.id, "demo-large"); + assert_eq!(p.models.len(), 2); + let m = p + .models + .iter() + .find(|m| m.id == "demo-large") + .expect("demo-large"); assert!(m.reasoning); assert!(m.tool_call); assert_eq!(m.context, Some(128000)); assert_eq!(m.cost_in, Some(1.5)); assert_eq!(m.cost_out, Some(6.0)); + assert_eq!(m.cost_cache_read, Some(0.15)); + assert_eq!(m.cost_cache_write, Some(1.875)); + let free = p + .models + .iter() + .find(|m| m.id == "demo-free") + .expect("demo-free"); + assert_eq!(free.cost_in, Some(0.0)); + assert_eq!(free.cost_cache_read, None); + assert_eq!(free.cost_cache_write, None); } #[test] diff --git a/src/components/token-usage/token-usage-page.tsx b/src/components/token-usage/token-usage-page.tsx index 841ff8e90..3724510d8 100644 --- a/src/components/token-usage/token-usage-page.tsx +++ b/src/components/token-usage/token-usage-page.tsx @@ -45,6 +45,7 @@ import { WorkbenchPageTitle } from "@/components/workbench/workbench-page-title" import { FolderAliasLabel } from "@/components/conversations/folder-alias-label" import { formatFolderLabelWithAlias } from "@/lib/folder-display" import { + opencodeProviderCatalog, tokenUsageFacets, tokenUsageReport, tokenUsageStatus, @@ -65,6 +66,7 @@ import { foldBreakdown, formatDuration, formatTokensPrecise, + formatUsd, freshTokens, idleDays, localTzOffsetMinutes, @@ -75,9 +77,16 @@ import { suggestBucket, type TokenUsageRangePreset, } from "@/lib/token-usage" +import { + buildRateIndex, + estimateItemCost, + estimateReportCost, + resolveRate, +} from "@/lib/model-api-rates" import { cn } from "@/lib/utils" import type { AgentType, + OpenCodeCatalogProvider, TokenUsageBucket, TokenUsageFacets, TokenUsageReport, @@ -328,6 +337,7 @@ export function TokenUsagePage() { const [facets, setFacets] = useState(null) const [status, setStatus] = useState(null) const [report, setReport] = useState(null) + const [catalog, setCatalog] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [syncing, setSyncing] = useState(false) @@ -432,6 +442,23 @@ export function TokenUsagePage() { void load() }, [load]) + // Independent of the usage report: the same 24h models.dev catalog the + // OpenCode settings page already loads. Failures stay quiet and hide the + // dollar line instead of blocking the token counts. + useEffect(() => { + let cancelled = false + void opencodeProviderCatalog() + .then((list) => { + if (!cancelled) setCatalog(list) + }) + .catch(() => { + if (!cancelled) setCatalog([]) + }) + return () => { + cancelled = true + } + }, []) + // Latest-ref so the event subscription below is set up once, not torn down // and re-established on every filter change (`load`'s identity tracks the // filters). Same idiom as tasks-view-context. @@ -561,6 +588,15 @@ export function TokenUsagePage() { const totals = report?.totals const cache = totals ? cacheHitRate(totals) : null + const rateIndex = useMemo( + () => (catalog && catalog.length > 0 ? buildRateIndex(catalog) : null), + [catalog] + ) + const apiEstimate = useMemo(() => { + if (!report || !rateIndex) return null + const estimate = estimateReportCost(report.by_model, rateIndex) + return estimate.coverage > 0 && estimate.usd > 0 ? estimate : null + }, [report, rateIndex]) const heat = useMemo( () => buildHeatMatrix(report?.heatmap ?? []), [report?.heatmap] @@ -687,13 +723,23 @@ export function TokenUsagePage() { const { shown, other } = foldBreakdown(breakdownItems, BREAKDOWN_LIMIT) const share = (v: number) => totals.total_tokens > 0 ? v / totals.total_tokens : null - const rows: RankedDatum[] = shown.map((it) => ({ - key: it.key, - label: breakdownLabel(it.key, it.label), - value: it.total_tokens, - share: share(it.total_tokens), - hint: t("sessionsCount", { count: it.conversation_count }), - })) + const rows: RankedDatum[] = shown.map((it) => { + const sessions = t("sessionsCount", { count: it.conversation_count }) + let hint = sessions + if (dim === "model" && rateIndex) { + const cost = estimateItemCost(it, resolveRate(rateIndex, it.key)) + if (cost.usd != null && cost.usd > 0) { + hint = `${sessions} · ${formatUsd(cost.usd)}` + } + } + return { + key: it.key, + label: breakdownLabel(it.key, it.label), + value: it.total_tokens, + share: share(it.total_tokens), + hint, + } + }) if (other) { rows.push({ key: other.key, @@ -705,7 +751,7 @@ export function TokenUsagePage() { }) } return rows - }, [breakdownItems, breakdownLabel, totals, t]) + }, [breakdownItems, breakdownLabel, totals, t, dim, rateIndex]) const onBreakdownSelect = useCallback( (key: string) => { @@ -1106,6 +1152,22 @@ export function TokenUsagePage() { /> )} + {apiEstimate && ( +
+ + {apiEstimate.coverage < 0.99 + ? t("apiListEstimatePartial", { + amount: formatUsd(apiEstimate.usd), + percent: Math.floor( + apiEstimate.coverage * 100 + ), + }) + : t("apiListEstimate", { + amount: formatUsd(apiEstimate.usd), + })} + +
+ )} {archetype && totals.total_tokens > 0 && ( // The outer wrapper owns the spacing: mt-auto pins // the strip to the card's bottom edge on wide diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index bdf81590d..bc58d2747 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4951,6 +4951,9 @@ "emptyHint": "يقرأ codeg أعداد الرموز مباشرة من سجل كل وكيل. حدّث لتُحسب الجلسات الموجودة على هذا الجهاز.", "emptyAction": "احسب جلساتي", "loadFailed": "تعذّر تحميل بيانات الاستهلاك", - "truncatedNotice": "هذا النطاق واسع جدًا — الأرقام تغطي أحدث جزء منه فقط." + "truncatedNotice": "هذا النطاق واسع جدًا — الأرقام تغطي أحدث جزء منه فقط.", + "apiListEstimate": "≈ {amount} قائمة API", + "apiListEstimatePartial": "≈ {amount} قائمة API · {percent}٪ من الرموز", + "apiListEstimateHint": "أسعار قائمة API العامة من models.dev، وتشمل قراءة وكتابة الذاكرة المؤقتة. خطط الاشتراك تُحاسب بشكل مختلف." } } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 0c40db5d3..c453b07e8 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4951,6 +4951,9 @@ "emptyHint": "Codeg liest die Token-Zahlen direkt aus dem Protokoll jedes Agenten. Aktualisiere, um zu zählen, was schon auf diesem Rechner liegt.", "emptyAction": "Meine Sitzungen zählen", "loadFailed": "Verbrauch konnte nicht geladen werden", - "truncatedNotice": "Dieser Zeitraum ist sehr groß — die Zahlen decken nur seinen jüngsten Abschnitt ab." + "truncatedNotice": "Dieser Zeitraum ist sehr groß — die Zahlen decken nur seinen jüngsten Abschnitt ab.", + "apiListEstimate": "≈ {amount} API-Liste", + "apiListEstimatePartial": "≈ {amount} API-Liste · {percent}% der Tokens", + "apiListEstimateHint": "Öffentliche API-Listenpreise von models.dev, inklusive Cache-Lesen und -Schreiben. Abo-Tarife rechnen anders ab." } } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 273af3aa0..2a71c74aa 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4951,6 +4951,9 @@ "emptyHint": "Codeg reads token counts straight from each agent's own transcript. Run a refresh to count what's already on this machine.", "emptyAction": "Count my sessions", "loadFailed": "Could not load usage", - "truncatedNotice": "This range is very large — the numbers cover only the most recent slice of it." + "truncatedNotice": "This range is very large — the numbers cover only the most recent slice of it.", + "apiListEstimate": "≈ {amount} API list", + "apiListEstimatePartial": "≈ {amount} API list · {percent}% of tokens", + "apiListEstimateHint": "Public API list rates from models.dev, including cache reads and writes. Subscription plans bill differently." } } diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 9948070a4..1cff8f36c 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4951,6 +4951,9 @@ "emptyHint": "Codeg lee los tokens directamente de la transcripción de cada agente. Actualiza para contabilizar lo que ya hay en esta máquina.", "emptyAction": "Contabilizar mis sesiones", "loadFailed": "No se pudo cargar el uso", - "truncatedNotice": "Este periodo es muy amplio: las cifras cubren solo su tramo más reciente." + "truncatedNotice": "Este periodo es muy amplio: las cifras cubren solo su tramo más reciente.", + "apiListEstimate": "≈ {amount} tarifa API", + "apiListEstimatePartial": "≈ {amount} tarifa API · {percent}% de tokens", + "apiListEstimateHint": "Tarifas públicas de API de models.dev, con lecturas y escrituras de caché. Los planes de suscripción facturan distinto." } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 9638508d6..bc8073b50 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4951,6 +4951,9 @@ "emptyHint": "Codeg lit les tokens directement dans la transcription de chaque agent. Actualisez pour compter ce qui est déjà sur cette machine.", "emptyAction": "Compter mes sessions", "loadFailed": "Impossible de charger la consommation", - "truncatedNotice": "Cette période est très large — les chiffres ne couvrent que sa portion la plus récente." + "truncatedNotice": "Cette période est très large — les chiffres ne couvrent que sa portion la plus récente.", + "apiListEstimate": "≈ {amount} tarif API", + "apiListEstimatePartial": "≈ {amount} tarif API · {percent}% des jetons", + "apiListEstimateHint": "Tarifs publics API issus de models.dev, lectures et écritures de cache comprises. Les abonnements facturent autrement." } } diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index f44e8c88d..fee1acf19 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4951,6 +4951,9 @@ "emptyHint": "codeg は各エージェント自身のセッション記録から直接トークン数を読み取ります。更新すると、このマシンにある記録を集計します。", "emptyAction": "セッションを集計", "loadFailed": "使用量を読み込めませんでした", - "truncatedNotice": "期間が非常に長いため、表示中の数値は直近の一部のみを対象としています。" + "truncatedNotice": "期間が非常に長いため、表示中の数値は直近の一部のみを対象としています。", + "apiListEstimate": "≈ {amount} APIリスト", + "apiListEstimatePartial": "≈ {amount} APIリスト · トークンの {percent}%", + "apiListEstimateHint": "models.dev の公開 API リスト料金です。キャッシュの読み書きを含みます。サブスクリプションの課金とは異なります。" } } diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index d8c404715..d82dd3347 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4951,6 +4951,9 @@ "emptyHint": "codeg는 각 에이전트의 세션 기록에서 토큰 수를 직접 읽습니다. 새로 고침하면 이 컴퓨터에 있는 기록을 집계합니다.", "emptyAction": "내 세션 집계", "loadFailed": "사용량을 불러오지 못했습니다", - "truncatedNotice": "기간이 매우 길어 표시된 수치는 가장 최근 구간만 반영합니다." + "truncatedNotice": "기간이 매우 길어 표시된 수치는 가장 최근 구간만 반영합니다.", + "apiListEstimate": "≈ {amount} API 정가", + "apiListEstimatePartial": "≈ {amount} API 정가 · 토큰 {percent}%", + "apiListEstimateHint": "models.dev의 공개 API 정가이며 캐시 읽기/쓰기를 포함합니다. 구독 요금과는 다릅니다." } } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 5b31f9ead..39db7703d 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4951,6 +4951,9 @@ "emptyHint": "O codeg lê os tokens direto da transcrição de cada agente. Atualize para contabilizar o que já existe nesta máquina.", "emptyAction": "Contabilizar minhas sessões", "loadFailed": "Não foi possível carregar o uso", - "truncatedNotice": "Este período é muito amplo — os números cobrem apenas o trecho mais recente." + "truncatedNotice": "Este período é muito amplo — os números cobrem apenas o trecho mais recente.", + "apiListEstimate": "≈ {amount} lista da API", + "apiListEstimatePartial": "≈ {amount} lista da API · {percent}% dos tokens", + "apiListEstimateHint": "Preços públicos da API em models.dev, incluindo leituras e escritas de cache. Planos de assinatura cobram diferente." } } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f6a2638c1..8b5590f16 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4951,6 +4951,9 @@ "emptyHint": "codeg 直接从各智能体自己的会话记录里读取 Token 数。点一下刷新,把本机已有的会话统计进来。", "emptyAction": "统计我的会话", "loadFailed": "用量加载失败", - "truncatedNotice": "该范围过大 —— 下面的数字只覆盖了其中最近的一段。" + "truncatedNotice": "该范围过大 —— 下面的数字只覆盖了其中最近的一段。", + "apiListEstimate": "≈ {amount} API 标价", + "apiListEstimatePartial": "≈ {amount} API 标价 · {percent}% 的 token", + "apiListEstimateHint": "来自 models.dev 的公开 API 标价,含缓存读写。订阅套餐的计费方式不同。" } } diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 13e7b6eae..7bb9da0b1 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -4951,6 +4951,9 @@ "emptyHint": "codeg 直接從各智慧代理自己的工作階段紀錄讀取 Token 數。點一下重新整理,把本機已有的紀錄統計進來。", "emptyAction": "統計我的工作階段", "loadFailed": "用量載入失敗", - "truncatedNotice": "此範圍過大 —— 下面的數字只涵蓋其中最近的一段。" + "truncatedNotice": "此範圍過大 —— 下面的數字只涵蓋其中最近的一段。", + "apiListEstimate": "≈ {amount} API 標價", + "apiListEstimatePartial": "≈ {amount} API 標價 · {percent}% 的 token", + "apiListEstimateHint": "來自 models.dev 的公開 API 標價,含快取讀寫。訂閱方案的計費方式不同。" } } diff --git a/src/lib/model-api-rates.test.ts b/src/lib/model-api-rates.test.ts new file mode 100644 index 000000000..d565031bf --- /dev/null +++ b/src/lib/model-api-rates.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from "vitest" + +import { + buildRateIndex, + estimateItemCost, + estimateReportCost, + pickRate, + resolveRate, + type ModelApiRate, +} from "./model-api-rates" +import type { OpenCodeCatalogProvider, TokenUsageBreakdownItem } from "./types" + +function model( + over: Partial & { id: string } +): OpenCodeCatalogProvider["models"][number] { + return { + name: over.id, + reasoning: false, + tool_call: true, + context: 200_000, + cost_in: 3, + cost_out: 15, + cost_cache_read: 0.3, + cost_cache_write: 3.75, + ...over, + } +} + +function provider( + id: string, + models: OpenCodeCatalogProvider["models"], + over: Partial = {} +): OpenCodeCatalogProvider { + return { + id, + name: id, + npm: null, + env: [], + doc: null, + auth_kind: "api", + models, + ...over, + } +} + +function item( + key: string, + over: Partial = {} +): TokenUsageBreakdownItem { + return { + key, + label: key, + input_tokens: 0, + output_tokens: 0, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: 0, + turn_count: 1, + conversation_count: 1, + ...over, + } +} + +function rate(over: Partial = {}): ModelApiRate { + return { + providerId: "anthropic", + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + ...over, + } +} + +const CATALOG: OpenCodeCatalogProvider[] = [ + provider("anthropic", [ + model({ + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + }), + ]), + provider("openrouter", [ + model({ + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + cost_in: 3.3, + cost_out: 16.5, + cost_cache_read: 0.33, + cost_cache_write: 4.125, + }), + model({ + id: "gpt-5", + name: "GPT-5", + cost_in: 1.25, + cost_out: 10, + cost_cache_read: 0.125, + cost_cache_write: null, + }), + ]), + provider("openai", [ + model({ + id: "gpt-5", + name: "GPT-5", + cost_in: 1.25, + cost_out: 10, + cost_cache_read: 0.125, + cost_cache_write: null, + }), + model({ + id: "gpt-5.3", + name: "GPT-5.3", + cost_in: 1.75, + cost_out: 14, + cost_cache_read: 0.175, + cost_cache_write: null, + }), + ]), + provider("xai", [ + model({ + id: "grok-4", + name: "Grok 4", + cost_in: 3, + cost_out: 15, + cost_cache_read: 0.75, + cost_cache_write: null, + }), + ]), +] + +describe("resolveRate", () => { + const index = buildRateIndex(CATALOG) + + it("matches an exact catalog id", () => { + expect(resolveRate(index, "claude-sonnet-4-5")?.providerId).toBe( + "anthropic" + ) + }) + + it("strips a dated snapshot suffix", () => { + expect(resolveRate(index, "claude-sonnet-4-5-20250929")?.id).toBe( + "claude-sonnet-4-5" + ) + }) + + it("honors an explicit provider prefix", () => { + expect(resolveRate(index, "openrouter/claude-sonnet-4-5")?.providerId).toBe( + "openrouter" + ) + }) + + it("matches a display name", () => { + expect(resolveRate(index, "Claude Sonnet 4.5")?.id).toBe( + "claude-sonnet-4-5" + ) + }) + + it("does not let gpt-5 steal gpt-5.3", () => { + expect(resolveRate(index, "gpt-5.3")?.id).toBe("gpt-5.3") + expect(resolveRate(index, "gpt-5")?.id).toBe("gpt-5") + }) + + it("accepts a numeric build suffix on an otherwise exact id", () => { + expect(resolveRate(index, "grok-4-0709")?.id).toBe("grok-4") + }) + + it("does not invent a rate for an unknown or folded key", () => { + expect(resolveRate(index, "mystery-model-9")).toBeNull() + expect(resolveRate(index, "__unknown__")).toBeNull() + expect(resolveRate(index, "__other__")).toBeNull() + expect(resolveRate(index, "")).toBeNull() + }) +}) + +describe("pickRate", () => { + it("prefers the lab listing when two providers disagree", () => { + expect( + pickRate([ + rate({ providerId: "openrouter", input: 9 }), + rate({ providerId: "anthropic", input: 3 }), + ])?.providerId + ).toBe("anthropic") + }) + + it("refuses to guess when two official listings disagree", () => { + expect( + pickRate([ + rate({ providerId: "anthropic", input: 3 }), + rate({ providerId: "amazon-bedrock", input: 3.6 }), + ]) + ).toBeNull() + }) + + it("collapses identical prices", () => { + expect( + pickRate([ + rate({ providerId: "openrouter" }), + rate({ providerId: "anthropic" }), + ])?.providerId + ).toBe("anthropic") + }) +}) + +describe("estimateItemCost", () => { + it("applies the four official list buckets", () => { + const cost = estimateItemCost( + item("claude-sonnet-4-5", { + input_tokens: 1_000_000, + output_tokens: 1_000_000, + cache_creation_tokens: 1_000_000, + cache_read_tokens: 1_000_000, + }), + rate() + ) + expect(cost.usd).toBeCloseTo(3 + 15 + 3.75 + 0.3) + expect(cost.pricedTokens).toBe(4_000_000) + expect(cost.unpricedTokens).toBe(0) + }) + + it("leaves cache tokens unpriced when the catalog omitted that rate", () => { + const cost = estimateItemCost( + item("gpt-5", { + input_tokens: 1_000_000, + cache_read_tokens: 500_000, + cache_creation_tokens: 100_000, + }), + rate({ cacheRead: null, cacheWrite: null, input: 1.25, output: 10 }) + ) + expect(cost.usd).toBeCloseTo(1.25) + expect(cost.pricedTokens).toBe(1_000_000) + expect(cost.unpricedTokens).toBe(600_000) + }) + + it("returns no dollar amount when nothing could be priced", () => { + const cost = estimateItemCost( + item("mystery", { input_tokens: 10, output_tokens: 4 }), + null + ) + expect(cost.usd).toBeNull() + expect(cost.unpricedTokens).toBe(14) + }) +}) + +describe("estimateReportCost", () => { + const index = buildRateIndex(CATALOG) + + it("sums only the priced models and reports coverage", () => { + const report = estimateReportCost( + [ + item("claude-sonnet-4-5", { + input_tokens: 1_000_000, + total_tokens: 1_000_000, + }), + item("mystery-model", { + input_tokens: 250_000, + total_tokens: 250_000, + }), + ], + index + ) + expect(report.usd).toBeCloseTo(3) + expect(report.pricedTokens).toBe(1_000_000) + expect(report.unpricedTokens).toBe(250_000) + expect(report.coverage).toBeCloseTo(0.8) + expect(report.pricedModels).toBe(1) + expect(report.unpricedModels).toBe(1) + }) +}) diff --git a/src/lib/model-api-rates.ts b/src/lib/model-api-rates.ts new file mode 100644 index 000000000..2b22d0881 --- /dev/null +++ b/src/lib/model-api-rates.ts @@ -0,0 +1,298 @@ +import type { + OpenCodeCatalogProvider, + TokenUsageBreakdownItem, +} from "@/lib/types" + +/** + * Public API list rates from the models.dev catalog Codeg already fetches. + * + * This is the official published per-million price (input, output, cache + * read, cache write). It is not what a subscription plan charged, and it is + * not a live remaining-quota figure. + */ + +export interface ModelApiRate { + providerId: string + id: string + name: string + /** USD per 1M tokens. `null` means that bucket is unpublished. */ + input: number | null + output: number | null + cacheRead: number | null + cacheWrite: number | null +} + +export interface RateIndex { + byKey: Map + byName: Map + ids: string[] +} + +export interface ItemCost { + /** `null` when no published rate applied to any token. */ + usd: number | null + pricedTokens: number + unpricedTokens: number +} + +export interface ReportCost { + usd: number + pricedTokens: number + unpricedTokens: number + /** priced / (priced + unpriced). 0 when nothing was recorded. */ + coverage: number + pricedModels: number + unpricedModels: number +} + +/** Prefer the lab's own listing when the same id is sold by many providers. */ +const PROVIDER_RANK: Record = { + anthropic: 0, + openai: 1, + google: 2, + xai: 3, + groq: 4, + mistral: 5, + deepseek: 6, + "amazon-bedrock": 7, + azure: 8, +} + +const DATE_SUFFIX = /-(?:\d{8}|\d{4}-\d{2}-\d{2})$/ +const PROVIDER_PREFIX = /^[a-z0-9][a-z0-9._-]*\// + +function finiteOrNull(n: number | null | undefined): number | null { + return typeof n === "number" && Number.isFinite(n) ? n : null +} + +export function normalizeModelKey(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/[_\s]+/g, "-") +} + +function stripProvider(key: string): string { + return key.replace(PROVIDER_PREFIX, "") +} + +function stripDate(key: string): string { + return key.replace(DATE_SUFFIX, "") +} + +function lookupKeys(raw: string): string[] { + const n = normalizeModelKey(raw) + if (!n) return [] + const noProv = stripProvider(n) + const keys = [n, noProv, stripDate(n), stripDate(noProv)] + return [...new Set(keys.filter(Boolean))] +} + +function priceKey(rate: ModelApiRate): string { + return [rate.input, rate.output, rate.cacheRead, rate.cacheWrite].join("|") +} + +function providerRank(id: string): number { + return PROVIDER_RANK[id] ?? 100 +} + +function push( + map: Map, + key: string, + rate: ModelApiRate +): void { + if (!key) return + const existing = map.get(key) + if (existing) existing.push(rate) + else map.set(key, [rate]) +} + +function toRate( + providerId: string, + model: OpenCodeCatalogProvider["models"][number] +): ModelApiRate | null { + const input = finiteOrNull(model.cost_in) + const output = finiteOrNull(model.cost_out) + const cacheRead = finiteOrNull(model.cost_cache_read) + const cacheWrite = finiteOrNull(model.cost_cache_write) + if ( + input == null && + output == null && + cacheRead == null && + cacheWrite == null + ) { + return null + } + return { + providerId, + id: model.id, + name: model.name, + input, + output, + cacheRead, + cacheWrite, + } +} + +/** Collapse several catalog hits to one rate, or none if the price is ambiguous. */ +export function pickRate(rates: ModelApiRate[]): ModelApiRate | null { + if (rates.length === 0) return null + if (rates.length === 1) return rates[0] + + const uniquePrices = new Set(rates.map(priceKey)) + const ranked = [...rates].sort( + (a, b) => + providerRank(a.providerId) - providerRank(b.providerId) || + a.providerId.localeCompare(b.providerId) + ) + if (uniquePrices.size === 1) return ranked[0] + + const official = ranked.filter((r) => providerRank(r.providerId) < 100) + if (official.length === 1) return official[0] + if (official.length > 1) { + const officialPrices = new Set(official.map(priceKey)) + if (officialPrices.size === 1) return official[0] + return null + } + return null +} + +export function buildRateIndex( + providers: OpenCodeCatalogProvider[] +): RateIndex { + const byKey = new Map() + const byName = new Map() + const idSet = new Set() + + for (const provider of providers) { + for (const model of provider.models) { + const rate = toRate(provider.id, model) + if (!rate) continue + const id = normalizeModelKey(model.id) + const prefixed = `${normalizeModelKey(provider.id)}/${id}` + for (const key of lookupKeys(id)) push(byKey, key, rate) + push(byKey, prefixed, rate) + idSet.add(id) + const name = normalizeModelKey(model.name) + if (name.length >= 3) push(byName, name, rate) + } + } + + return { byKey, byName, ids: [...idSet] } +} + +export function resolveRate( + index: RateIndex, + raw: string | null | undefined +): ModelApiRate | null { + if (!raw) return null + const trimmed = raw.trim() + if (!trimmed || trimmed === "__unknown__" || trimmed === "__other__") { + return null + } + + for (const key of lookupKeys(trimmed)) { + const hit = pickRate(index.byKey.get(key) ?? []) + if (hit) return hit + } + + for (const key of lookupKeys(trimmed)) { + const hit = pickRate(index.byName.get(key) ?? []) + if (hit) return hit + } + + // `claude-sonnet-4-5-20250929` already matches via the date strip. This + // last pass only accepts a numeric build suffix (`grok-4-0709`) so + // `gpt-5` never steals `gpt-5.3` or `gpt-5-mini`. + const bare = stripProvider(normalizeModelKey(trimmed)) + const prefixHits: ModelApiRate[] = [] + for (const id of index.ids) { + if (!bare.startsWith(`${id}-`)) continue + if (!/^-\d{4,8}$/.test(bare.slice(id.length))) continue + prefixHits.push(...(index.byKey.get(id) ?? [])) + } + return pickRate(prefixHits) +} + +function priceBucket( + tokens: number, + perMillion: number | null +): { usd: number; priced: number; unpriced: number } { + if (tokens <= 0) return { usd: 0, priced: 0, unpriced: 0 } + if (perMillion == null) return { usd: 0, priced: 0, unpriced: tokens } + return { + usd: (tokens / 1_000_000) * perMillion, + priced: tokens, + unpriced: 0, + } +} + +export function estimateItemCost( + item: Pick< + TokenUsageBreakdownItem, + | "input_tokens" + | "output_tokens" + | "cache_creation_tokens" + | "cache_read_tokens" + >, + rate: ModelApiRate | null +): ItemCost { + if (!rate) { + const unpriced = + item.input_tokens + + item.output_tokens + + item.cache_creation_tokens + + item.cache_read_tokens + return { usd: null, pricedTokens: 0, unpricedTokens: unpriced } + } + + const input = priceBucket(item.input_tokens, rate.input) + const output = priceBucket(item.output_tokens, rate.output) + const cacheWrite = priceBucket(item.cache_creation_tokens, rate.cacheWrite) + const cacheRead = priceBucket(item.cache_read_tokens, rate.cacheRead) + const pricedTokens = + input.priced + output.priced + cacheWrite.priced + cacheRead.priced + const unpricedTokens = + input.unpriced + output.unpriced + cacheWrite.unpriced + cacheRead.unpriced + if (pricedTokens <= 0) { + return { usd: null, pricedTokens: 0, unpricedTokens } + } + return { + usd: input.usd + output.usd + cacheWrite.usd + cacheRead.usd, + pricedTokens, + unpricedTokens, + } +} + +export function estimateReportCost( + items: TokenUsageBreakdownItem[], + index: RateIndex +): ReportCost { + let usd = 0 + let pricedTokens = 0 + let unpricedTokens = 0 + let pricedModels = 0 + let unpricedModels = 0 + + for (const item of items) { + const cost = estimateItemCost(item, resolveRate(index, item.key)) + if (cost.usd != null) { + usd += cost.usd + pricedModels += 1 + } else if (cost.unpricedTokens > 0) { + unpricedModels += 1 + } + pricedTokens += cost.pricedTokens + unpricedTokens += cost.unpricedTokens + } + + const total = pricedTokens + unpricedTokens + return { + usd, + pricedTokens, + unpricedTokens, + coverage: total > 0 ? pricedTokens / total : 0, + pricedModels, + unpricedModels, + } +} diff --git a/src/lib/opencode-connect.test.ts b/src/lib/opencode-connect.test.ts index c6c7cfb4a..49fadfd89 100644 --- a/src/lib/opencode-connect.test.ts +++ b/src/lib/opencode-connect.test.ts @@ -32,6 +32,8 @@ const CATALOG: OpenCodeCatalogProvider[] = [ context: 400000, cost_in: 1, cost_out: 8, + cost_cache_read: null, + cost_cache_write: null, }, ], }, @@ -51,6 +53,8 @@ const CATALOG: OpenCodeCatalogProvider[] = [ context: 200000, cost_in: 0.5, cost_out: 2, + cost_cache_read: null, + cost_cache_write: null, }, ], }, diff --git a/src/lib/token-usage.test.ts b/src/lib/token-usage.test.ts index 6d646ed85..787900fdf 100644 --- a/src/lib/token-usage.test.ts +++ b/src/lib/token-usage.test.ts @@ -12,6 +12,7 @@ import { foldBreakdown, formatDuration, formatTokensPrecise, + formatUsd, freshTokens, fromLocalDayValue, idleDays, @@ -562,6 +563,19 @@ describe("formatTokensPrecise", () => { }) }) +describe("formatUsd", () => { + it("keeps tiny list prices readable instead of rounding them to $0.00", () => { + expect(formatUsd(0.0042)).toBe("$0.0042") + expect(formatUsd(0.01)).toBe("$0.01") + }) + + it("prints ordinary amounts with two decimals", () => { + expect(formatUsd(12.4)).toBe("$12.40") + expect(formatUsd(0)).toBe("$0") + expect(formatUsd(1500)).toBe("$1,500.00") + }) +}) + describe("formatDuration", () => { it("drops to the two largest useful units", () => { expect(formatDuration(0)).toBe("0s") diff --git a/src/lib/token-usage.ts b/src/lib/token-usage.ts index f11f96ba9..2e59a73af 100644 --- a/src/lib/token-usage.ts +++ b/src/lib/token-usage.ts @@ -498,6 +498,23 @@ export function formatTokensPrecise(n: number): string { return Math.round(n).toLocaleString() } +/** + * USD list-price amounts for the usage hero. Always a dollar figure, never + * localized into another currency: the catalog rates are USD per 1M tokens. + */ +export function formatUsd(n: number): string { + if (!Number.isFinite(n) || n <= 0) return "$0" + if (n < 0.01) { + const trimmed = n.toFixed(4).replace(/0+$/, "").replace(/\.$/, "") + return `$${trimmed}` + } + if (n < 1000) return `$${n.toFixed(2)}` + return `$${n.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}` +} + /** `1h 24m` / `3m 05s` / `12s` — generation time, never zero-padded hours. */ export function formatDuration(ms: number): string { if (!Number.isFinite(ms) || ms <= 0) return "0s" diff --git a/src/lib/types.ts b/src/lib/types.ts index 8404ddc14..00d8f5d21 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -3486,6 +3486,8 @@ export interface OpenCodeCatalogModel { context: number | null cost_in: number | null cost_out: number | null + cost_cache_read: number | null + cost_cache_write: number | null } /**