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 } /**