From d6ef0b4e45ee7adaa7b612a342cc68c8625c1482 Mon Sep 17 00:00:00 2001 From: Meinianda-L Date: Sat, 8 Aug 2026 17:08:58 +0800 Subject: [PATCH 1/2] feat: show per-message token usage and estimated cost (USD) in chat - core: PromptLog now carries the provider-reported usage and the raw model id, populated by BaseLLM.streamChat - core: calculateRequestCost refactored into a shared helper with per-provider pricing tables; prices added for DeepSeek, Gemini and Mistral plus cache-read pricing for GPT-4o family, sourced from models.dev (same pricing DB used by OpenCode), display-only - gui: new UsageCost footer under assistant messages showing model, tokens and estimated USD cost; omitted when usage is unavailable or the model price is unknown - tests for cost calculation and the footer component Closes #1703 --- core/index.d.ts | 4 + core/llm/index.ts | 2 + core/llm/utils/calculateRequestCost.test.ts | 179 +++++++++ core/llm/utils/calculateRequestCost.ts | 350 ++++++++++-------- .../StepContainer/StepContainer.tsx | 3 + .../StepContainer/UsageCost.test.tsx | 73 ++++ .../components/StepContainer/UsageCost.tsx | 65 ++++ 7 files changed, 526 insertions(+), 150 deletions(-) create mode 100644 core/llm/utils/calculateRequestCost.test.ts create mode 100644 gui/src/components/StepContainer/UsageCost.test.tsx create mode 100644 gui/src/components/StepContainer/UsageCost.tsx diff --git a/core/index.d.ts b/core/index.d.ts index bec3e0e0ff8..2f235b92767 100644 --- a/core/index.d.ts +++ b/core/index.d.ts @@ -488,8 +488,12 @@ export type FileSymbolMap = Record; export interface PromptLog { modelTitle: string; modelProvider: string; + /** The actual model ID used for the request */ + model?: string; prompt: string; completion: string; + /** Token usage reported by the provider for this request, if available */ + usage?: Usage; } export type MessageModes = "chat" | "agent" | "plan" | "background"; diff --git a/core/llm/index.ts b/core/llm/index.ts index 1af44b25614..d8ad5488cd7 100644 --- a/core/llm/index.ts +++ b/core/llm/index.ts @@ -1330,8 +1330,10 @@ export abstract class BaseLLM implements ILLM { return { modelTitle: this.title ?? completionOptions.model, modelProvider: this.underlyingProviderName, + model: completionOptions.model, prompt, completion: completion.join(""), + usage, }; } diff --git a/core/llm/utils/calculateRequestCost.test.ts b/core/llm/utils/calculateRequestCost.test.ts new file mode 100644 index 00000000000..ea5884e88c5 --- /dev/null +++ b/core/llm/utils/calculateRequestCost.test.ts @@ -0,0 +1,179 @@ +import { Usage } from "../.."; +import { calculateRequestCost } from "./calculateRequestCost"; + +describe("calculateRequestCost", () => { + const usage = ( + promptTokens: number, + completionTokens: number, + promptTokensDetails?: Usage["promptTokensDetails"], + ): Usage => ({ + promptTokens, + completionTokens, + promptTokensDetails, + }); + + it("returns null for an unknown provider", () => { + expect( + calculateRequestCost( + "unknown-provider", + "claude-sonnet-4-6", + usage(1000, 500), + ), + ).toBeNull(); + }); + + it("returns null for an unknown model", () => { + expect( + calculateRequestCost("anthropic", "not-a-real-model", usage(1000, 500)), + ).toBeNull(); + }); + + it("returns null for a model that only matches a known provider of another family", () => { + expect( + calculateRequestCost("openai", "claude-sonnet-4-6", usage(1000, 500)), + ).toBeNull(); + }); + + it("calculates input and output costs for a known Anthropic model", () => { + const result = calculateRequestCost( + "anthropic", + "claude-sonnet-4-6", + usage(1_000_000, 1_000_000), + ); + expect(result).not.toBeNull(); + // $3/MTok input + $15/MTok output + expect(result!.cost).toBeCloseTo(3 + 15, 6); + }); + + it("calculates input and output costs for a known OpenAI model", () => { + const result = calculateRequestCost( + "openai", + "gpt-4o-mini", + usage(1_000_000, 1_000_000), + ); + expect(result).not.toBeNull(); + // $0.15/MTok input + $0.6/MTok output + expect(result!.cost).toBeCloseTo(0.15 + 0.6, 6); + }); + + it("calculates input and output costs for a known DeepSeek model", () => { + const result = calculateRequestCost( + "deepseek", + "deepseek-chat", + usage(1_000_000, 1_000_000), + ); + expect(result).not.toBeNull(); + expect(result!.cost).toBeCloseTo(0.14 + 0.28, 6); + }); + + it("calculates input and output costs for a known Gemini model", () => { + const result = calculateRequestCost( + "gemini", + "gemini-2.5-pro", + usage(1_000_000, 1_000_000), + ); + expect(result).not.toBeNull(); + expect(result!.cost).toBeCloseTo(1.25 + 10, 6); + }); + + it("calculates input and output costs for a known Mistral model", () => { + const result = calculateRequestCost( + "mistral", + "mistral-large-latest", + usage(1_000_000, 1_000_000), + ); + expect(result).not.toBeNull(); + expect(result!.cost).toBeCloseTo(0.5 + 1.5, 6); + }); + + it("matches model-family prefixes case-insensitively", () => { + const result = calculateRequestCost( + "anthropic", + "CLAUDE-SONNET-4-6", + usage(1_000_000, 0), + ); + expect(result).not.toBeNull(); + expect(result!.cost).toBeCloseTo(3, 6); + }); + + it("matches model variants by prefix", () => { + const result = calculateRequestCost( + "anthropic", + "claude-sonnet-4-6-20260217", + usage(1_000_000, 0), + ); + expect(result).not.toBeNull(); + expect(result!.cost).toBeCloseTo(3, 6); + }); + + it("returns a cost of zero when there are no tokens", () => { + const result = calculateRequestCost( + "anthropic", + "claude-sonnet-4-6", + usage(0, 0), + ); + expect(result).not.toBeNull(); + expect(result!.cost).toBe(0); + }); + + it("computes fractional costs from partial token counts", () => { + const result = calculateRequestCost( + "anthropic", + "claude-sonnet-4-6", + usage(12_000, 4_000), + ); + expect(result).not.toBeNull(); + // 12_000 / 1M * $3 = $0.036 ; 4_000 / 1M * $15 = $0.06 + expect(result!.cost).toBeCloseTo(0.036 + 0.06, 6); + }); + + it("includes cache write and cache read costs when reported", () => { + const result = calculateRequestCost( + "anthropic", + "claude-sonnet-4-6", + usage(10_000, 2_000, { + cachedTokens: 8_000, + cacheWriteTokens: 5_000, + }), + ); + expect(result).not.toBeNull(); + // Input: 10_000/1M * 3 = 0.03 ; Output: 2_000/1M * 15 = 0.03 + // Cache write: 5_000/1M * 3.75 = 0.01875 ; Cache read: 8_000/1M * 0.3 = 0.0024 + expect(result!.cost).toBeCloseTo(0.03 + 0.03 + 0.01875 + 0.0024, 6); + }); + + it("includes cache read cost for providers that only charge for reads", () => { + const result = calculateRequestCost( + "openai", + "gpt-4o", + usage(10_000, 2_000, { cachedTokens: 8_000 }), + ); + expect(result).not.toBeNull(); + // Input: 10_000/1M * 2.5 = 0.025 ; Output: 2_000/1M * 10 = 0.02 + // Cache read: 8_000/1M * 1.25 = 0.01 + expect(result!.cost).toBeCloseTo(0.025 + 0.02 + 0.01, 6); + }); + + it("ignores cache write tokens for providers that do not price cache writes", () => { + const result = calculateRequestCost( + "openai", + "gpt-4o", + usage(10_000, 0, { cachedTokens: 0, cacheWriteTokens: 5_000 }), + ); + expect(result).not.toBeNull(); + // Only input cost; cache write tokens must not be charged + expect(result!.cost).toBeCloseTo(0.025, 6); + }); + + it("builds a human-readable breakdown", () => { + const result = calculateRequestCost( + "anthropic", + "claude-sonnet-4-6", + usage(12_000, 4_000), + ); + expect(result!.breakdown).toContain("Model: claude-sonnet-4-6"); + expect(result!.breakdown).toContain("Input: 12,000 tokens"); + expect(result!.breakdown).toContain("Output: 4,000 tokens"); + expect(result!.breakdown).toContain("Total: $0.096000"); + }); +}); diff --git a/core/llm/utils/calculateRequestCost.ts b/core/llm/utils/calculateRequestCost.ts index 794524d448d..9087808b3dd 100644 --- a/core/llm/utils/calculateRequestCost.ts +++ b/core/llm/utils/calculateRequestCost.ts @@ -5,90 +5,187 @@ export interface CostBreakdown { breakdown: string; } -function calculateAnthropicCost( - model: string, - usage: Usage, -): CostBreakdown | null { +export interface ModelPricing { + /** USD per 1M tokens */ + input: number; + /** USD per 1M tokens */ + output: number; + /** USD per 1M tokens. Only providers that charge for cache writes set this. */ + cacheWrite?: number; + /** USD per 1M tokens. Only providers that charge for cache reads set this. */ + cacheRead?: number; +} + +type PricingTable = Record; + +// Prices are in USD per 1M tokens and come from models.dev +// (https://models.dev, the same open-source pricing database used by OpenCode), +// retrieved on 2026-08-08. +// +// Keys are model-family prefixes, matched longest-first. Display-only, not +// billing-authoritative: providers may change prices at any time. + +const ANTHROPIC_PRICING: PricingTable = { + // Claude Sonnet 4.6 + "claude-sonnet-4-6": { + input: 3, + output: 15, + cacheWrite: 3.75, + cacheRead: 0.3, + }, + + // Claude Opus 4.6 + "claude-opus-4-6": { + input: 5, + output: 25, + cacheWrite: 6.25, + cacheRead: 0.5, + }, + + // Claude Opus 4.5 (previous generation) + "claude-opus-4-5": { + input: 5, + output: 25, + cacheWrite: 6.25, + cacheRead: 0.5, + }, + + // Claude Sonnet 4.5 (previous generation) + "claude-sonnet-4-5": { + input: 3, + output: 15, + cacheWrite: 3.75, + cacheRead: 0.3, + }, + + // Claude Haiku 4.5 + "claude-haiku-4-5": { + input: 1, + output: 5, + cacheWrite: 1.25, + cacheRead: 0.1, + }, + + // Claude Opus 4 (legacy) + "claude-3-opus": { + input: 15, + output: 75, + cacheWrite: 18.75, + cacheRead: 1.5, + }, + + // Claude Sonnet 4 (legacy) + "claude-3-5-sonnet": { + input: 3, + output: 15, + cacheWrite: 3.75, + cacheRead: 0.3, + }, + + // Claude Haiku 3.5 (legacy) + "claude-3-5-haiku": { + input: 0.8, + output: 4, + cacheWrite: 1, + cacheRead: 0.08, + }, + + // Claude 3 Haiku (legacy) + "claude-3-haiku": { + input: 0.25, + output: 1.25, + cacheWrite: 0.3, + cacheRead: 0.03, + }, +}; + +const OPENAI_PRICING: PricingTable = { + // GPT-4o models (most specific first) + "gpt-4o-mini": { + input: 0.15, + output: 0.6, + cacheRead: 0.075, + }, + "gpt-4o": { + input: 2.5, + output: 10, + cacheRead: 1.25, + }, + + // GPT-4 Turbo models + "gpt-4-turbo": { input: 10, output: 30 }, + + // GPT-3.5 Turbo models (most specific first) + "gpt-3.5-turbo-0125": { input: 0.5, output: 1.5 }, + "gpt-3.5-turbo-1106": { input: 1, output: 2 }, + "gpt-3.5-turbo": { input: 1.5, output: 2 }, + + // Base GPT-4 (fallback for other gpt-4 variants) + "gpt-4": { input: 30, output: 60 }, +}; + +const DEEPSEEK_PRICING: PricingTable = { + "deepseek-reasoner": { + input: 0.14, + output: 0.28, + cacheRead: 0.0028, + }, + "deepseek-chat": { + input: 0.14, + output: 0.28, + cacheRead: 0.0028, + }, +}; + +const GEMINI_PRICING: PricingTable = { + // Gemini 2.5 Pro + "gemini-2.5-pro": { + input: 1.25, + output: 10, + cacheRead: 0.125, + }, + + // Gemini 2.5 Flash + "gemini-2.5-flash": { + input: 0.3, + output: 2.5, + cacheRead: 0.03, + }, + + // Gemini 2.0 Flash + "gemini-2.0-flash": { + input: 0.1, + output: 0.4, + cacheRead: 0.025, + }, +}; + +const MISTRAL_PRICING: PricingTable = { + "mistral-large-latest": { input: 0.5, output: 1.5 }, + "mistral-small-latest": { input: 0.15, output: 0.6 }, +}; + +function getPricing(pricing: PricingTable, model: string): ModelPricing | null { // Normalize model name to handle various formats const normalizedModel = model.toLowerCase(); - // Define pricing per million tokens (MTok) by model family prefix - const pricing: Record< - string, - { input: number; output: number; cacheWrite: number; cacheRead: number } - > = { - // Claude Sonnet 4.6 - "claude-sonnet-4-6": { - input: 3, - output: 15, - cacheWrite: 3.75, - cacheRead: 0.3, - }, - - // Claude Opus 4.6 - "claude-opus-4-6": { - input: 5, - output: 25, - cacheWrite: 6.25, - cacheRead: 0.5, - }, - - // Claude Opus 4.5 (previous generation) - "claude-opus-4-5": { - input: 5, - output: 25, - cacheWrite: 6.25, - cacheRead: 0.5, - }, - - // Claude Opus 4 (legacy) - "claude-3-opus": { - input: 15, - output: 75, - cacheWrite: 18.75, - cacheRead: 1.5, - }, - - // Claude Sonnet 4 (optimal balance) - "claude-3-5-sonnet": { - input: 3, - output: 15, - cacheWrite: 3.75, - cacheRead: 0.3, - }, - - // Claude Haiku 3.5 (fastest, most cost-effective) - "claude-3-5-haiku": { - input: 0.8, - output: 4, - cacheWrite: 1, - cacheRead: 0.08, - }, - - // Legacy Claude 3 Haiku - "claude-3-haiku": { - input: 0.25, - output: 1.25, - cacheWrite: 0.3, - cacheRead: 0.03, - }, - }; - // Sort keys by length (longest first) to match most specific patterns first const sortedKeys = Object.keys(pricing).sort((a, b) => b.length - a.length); - let modelPricing = null; for (const prefix of sortedKeys) { if (normalizedModel.startsWith(prefix)) { - modelPricing = pricing[prefix]; - break; + return pricing[prefix]; } } - if (!modelPricing) { - return null; // Unknown model - } + return null; // Unknown model +} +function calculateCost( + model: string, + usage: Usage, + modelPricing: ModelPricing, +): CostBreakdown { // Calculate costs const inputCost = (usage.promptTokens / 1_000_000) * modelPricing.input; const outputCost = (usage.completionTokens / 1_000_000) * modelPricing.output; @@ -115,7 +212,11 @@ function calculateAnthropicCost( if (usage.promptTokensDetails) { const { cachedTokens, cacheWriteTokens } = usage.promptTokensDetails; - if (cacheWriteTokens && cacheWriteTokens > 0) { + if ( + cacheWriteTokens && + cacheWriteTokens > 0 && + modelPricing.cacheWrite !== undefined + ) { const cacheWriteCost = (cacheWriteTokens / 1_000_000) * modelPricing.cacheWrite; cacheCost += cacheWriteCost; @@ -124,7 +225,11 @@ function calculateAnthropicCost( ); } - if (cachedTokens && cachedTokens > 0) { + if ( + cachedTokens && + cachedTokens > 0 && + modelPricing.cacheRead !== undefined + ) { const cacheReadCost = (cachedTokens / 1_000_000) * modelPricing.cacheRead; cacheCost += cacheReadCost; breakdownParts.push( @@ -148,91 +253,36 @@ function calculateAnthropicCost( }; } -function calculateOpenAICost( - model: string, - usage: Usage, -): CostBreakdown | null { - // Normalize model name - const normalizedModel = model.toLowerCase(); - - // Define pricing per million tokens (MTok) by model family prefix - const pricing: Record = { - // GPT-4o models (most specific first) - "gpt-4o-mini": { input: 0.15, output: 0.6 }, - "gpt-4o": { input: 2.5, output: 10 }, - - // GPT-4 Turbo models - "gpt-4-turbo": { input: 10, output: 30 }, - - // GPT-3.5 Turbo models (most specific first) - "gpt-3.5-turbo-0125": { input: 0.5, output: 1.5 }, - "gpt-3.5-turbo-1106": { input: 1, output: 2 }, - "gpt-3.5-turbo": { input: 1.5, output: 2 }, - - // Base GPT-4 (fallback for other gpt-4 variants) - "gpt-4": { input: 30, output: 60 }, - }; - - // Sort keys by length (longest first) to match most specific patterns first - const sortedKeys = Object.keys(pricing).sort((a, b) => b.length - a.length); - - let modelPricing = null; - for (const prefix of sortedKeys) { - if (normalizedModel.startsWith(prefix)) { - modelPricing = pricing[prefix]; - break; - } - } - - if (!modelPricing) { - return null; // Unknown model - } - - // Calculate costs - const inputCost = (usage.promptTokens / 1_000_000) * modelPricing.input; - const outputCost = (usage.completionTokens / 1_000_000) * modelPricing.output; - - // Build breakdown components - const breakdownParts: string[] = []; - - if (usage.promptTokens > 0) { - breakdownParts.push( - `Input: ${usage.promptTokens.toLocaleString()} tokens × $${modelPricing.input}/MTok = $${inputCost.toFixed(6)}`, - ); - } - - if (usage.completionTokens > 0) { - breakdownParts.push( - `Output: ${usage.completionTokens.toLocaleString()} tokens × $${modelPricing.output}/MTok = $${outputCost.toFixed(6)}`, - ); - } - - const totalCost = inputCost + outputCost; - - // Build final breakdown string - let breakdown = `Model: ${model}\n`; - breakdown += breakdownParts.join("\n"); - if (breakdownParts.length > 1) { - breakdown += `\nTotal: $${totalCost.toFixed(6)}`; - } - - return { - cost: totalCost, - breakdown, - }; -} - export function calculateRequestCost( provider: string, model: string, usage: Usage, ): CostBreakdown | null { + let pricing: PricingTable | null = null; switch (provider.toLowerCase()) { case "anthropic": - return calculateAnthropicCost(model, usage); + pricing = ANTHROPIC_PRICING; + break; case "openai": - return calculateOpenAICost(model, usage); + pricing = OPENAI_PRICING; + break; + case "deepseek": + pricing = DEEPSEEK_PRICING; + break; + case "gemini": + pricing = GEMINI_PRICING; + break; + case "mistral": + pricing = MISTRAL_PRICING; + break; default: return null; } + + const modelPricing = getPricing(pricing, model); + if (!modelPricing) { + return null; // Unknown model + } + + return calculateCost(model, usage, modelPricing); } diff --git a/gui/src/components/StepContainer/StepContainer.tsx b/gui/src/components/StepContainer/StepContainer.tsx index fc6981d0558..898157df398 100644 --- a/gui/src/components/StepContainer/StepContainer.tsx +++ b/gui/src/components/StepContainer/StepContainer.tsx @@ -10,6 +10,7 @@ import StyledMarkdownPreview from "../StyledMarkdownPreview"; import ConversationSummary from "./ConversationSummary"; import ResponseActions from "./ResponseActions"; import ThinkingIndicator from "./ThinkingIndicator"; +import UsageCost from "./UsageCost"; interface StepContainerProps { item: ChatHistoryItem; @@ -132,6 +133,8 @@ export default function StepContainer(props: StepContainerProps) { )} + + {/* ConversationSummary is outside the dimmed container so it's always at full opacity */} diff --git a/gui/src/components/StepContainer/UsageCost.test.tsx b/gui/src/components/StepContainer/UsageCost.test.tsx new file mode 100644 index 00000000000..7d154cafb14 --- /dev/null +++ b/gui/src/components/StepContainer/UsageCost.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from "@testing-library/react"; +import { ChatHistoryItem, PromptLog } from "core"; +import { describe, expect, it } from "vitest"; +import UsageCost from "./UsageCost"; + +function historyItem(promptLogs?: PromptLog[]): ChatHistoryItem { + return { + message: { role: "assistant", content: "hi" }, + contextItems: [], + promptLogs, + }; +} + +const usagePromptLog = (overrides: Partial = {}): PromptLog => ({ + modelTitle: "Claude Sonnet", + modelProvider: "anthropic", + model: "claude-sonnet-4-6", + prompt: "", + completion: "", + usage: { promptTokens: 12_000, completionTokens: 4_000 }, + ...overrides, +}); + +describe("UsageCost", () => { + it("renders nothing when there are no prompt logs", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when usage is unavailable", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows model, tokens, and cost for a known model", () => { + render(); + expect(screen.getByText(/Claude Sonnet/)).toBeInTheDocument(); + // 12_000 + 4_000 = 16,000 tokens + expect(screen.getByText(/16,000 tokens/)).toBeInTheDocument(); + // 12_000/1M * 3 + 4_000/1M * 15 = 0.096 + expect(screen.getByText(/\$0.0960/)).toBeInTheDocument(); + }); + + it("omits cost for an unknown model", () => { + render( + , + ); + expect(screen.getByText(/16,000 tokens/)).toBeInTheDocument(); + expect(screen.queryByText(/\$/)).not.toBeInTheDocument(); + }); + + it("aggregates tokens and cost across multiple prompt logs", () => { + render( + , + ); + expect(screen.getByText(/32,000 tokens/)).toBeInTheDocument(); + expect(screen.getByText(/\$0.1920/)).toBeInTheDocument(); + }); +}); diff --git a/gui/src/components/StepContainer/UsageCost.tsx b/gui/src/components/StepContainer/UsageCost.tsx new file mode 100644 index 00000000000..aeb6052551d --- /dev/null +++ b/gui/src/components/StepContainer/UsageCost.tsx @@ -0,0 +1,65 @@ +import { ChatHistoryItem } from "core"; +import { calculateRequestCost } from "core/llm/utils/calculateRequestCost"; + +interface UsageCostProps { + item: ChatHistoryItem; +} + +/** + * Renders a small footer under an assistant message showing the model, + * total token usage, and estimated cost (USD) for the request(s) that + * produced it. + * + * Costs are only shown when both token usage and a known model price are + * available; otherwise that part is omitted rather than displaying a + * made-up figure. + */ +export default function UsageCost({ item }: UsageCostProps) { + const promptLogs = item.promptLogs ?? []; + if (promptLogs.length === 0) { + return null; + } + + let promptTokens = 0; + let completionTokens = 0; + let cost = 0; + let costKnown = false; + + for (const log of promptLogs) { + if (!log.usage) { + continue; + } + promptTokens += log.usage.promptTokens; + completionTokens += log.usage.completionTokens; + if (log.modelProvider && log.model) { + const breakdown = calculateRequestCost( + log.modelProvider, + log.model, + log.usage, + ); + if (breakdown) { + cost += breakdown.cost; + costKnown = true; + } + } + } + + const totalTokens = promptTokens + completionTokens; + if (totalTokens === 0 && !costKnown) { + return null; + } + + const parts: string[] = [promptLogs[0].modelTitle]; + if (totalTokens > 0) { + parts.push(`${totalTokens.toLocaleString()} tokens`); + } + if (costKnown) { + parts.push(`$${cost.toFixed(4)}`); + } + + return ( +
+ {parts.join(" · ")} +
+ ); +} From 8aae931f2e0ae91d3e79bb8d17d910e360af802f Mon Sep 17 00:00:00 2001 From: Meinianda-L Date: Sat, 8 Aug 2026 17:51:29 +0800 Subject: [PATCH 2/2] refactor: move model pricing to JSON sourced from models.dev (same source as OpenCode) - new core/llm/utils/modelPricing.json: pricing extracted from the models.dev API (https://models.opencode.ai/api.json, the data source used by OpenCode), retrieved 2026-08-08, with source attribution in the file - calculateRequestCost reads pricing from the JSON instead of inline tables; behavior unchanged - align gpt-3.5-turbo pricing with models.dev (0.5/1.5) --- core/llm/utils/calculateRequestCost.test.ts | 11 ++ core/llm/utils/calculateRequestCost.ts | 181 ++------------------ core/llm/utils/modelPricing.json | 132 ++++++++++++++ 3 files changed, 156 insertions(+), 168 deletions(-) create mode 100644 core/llm/utils/modelPricing.json diff --git a/core/llm/utils/calculateRequestCost.test.ts b/core/llm/utils/calculateRequestCost.test.ts index ea5884e88c5..7008bb341df 100644 --- a/core/llm/utils/calculateRequestCost.test.ts +++ b/core/llm/utils/calculateRequestCost.test.ts @@ -56,6 +56,17 @@ describe("calculateRequestCost", () => { expect(result!.cost).toBeCloseTo(0.15 + 0.6, 6); }); + it("uses models.dev pricing for gpt-3.5-turbo", () => { + const result = calculateRequestCost( + "openai", + "gpt-3.5-turbo", + usage(1_000_000, 1_000_000), + ); + expect(result).not.toBeNull(); + // $0.5/MTok input + $1.5/MTok output (per models.dev, same source as OpenCode) + expect(result!.cost).toBeCloseTo(0.5 + 1.5, 6); + }); + it("calculates input and output costs for a known DeepSeek model", () => { const result = calculateRequestCost( "deepseek", diff --git a/core/llm/utils/calculateRequestCost.ts b/core/llm/utils/calculateRequestCost.ts index 9087808b3dd..c288360e755 100644 --- a/core/llm/utils/calculateRequestCost.ts +++ b/core/llm/utils/calculateRequestCost.ts @@ -1,3 +1,4 @@ +import modelPricing from "./modelPricing.json"; import { Usage } from "../.."; export interface CostBreakdown { @@ -16,154 +17,14 @@ export interface ModelPricing { cacheRead?: number; } -type PricingTable = Record; +export type PricingTable = Record; -// Prices are in USD per 1M tokens and come from models.dev -// (https://models.dev, the same open-source pricing database used by OpenCode), -// retrieved on 2026-08-08. -// -// Keys are model-family prefixes, matched longest-first. Display-only, not -// billing-authoritative: providers may change prices at any time. - -const ANTHROPIC_PRICING: PricingTable = { - // Claude Sonnet 4.6 - "claude-sonnet-4-6": { - input: 3, - output: 15, - cacheWrite: 3.75, - cacheRead: 0.3, - }, - - // Claude Opus 4.6 - "claude-opus-4-6": { - input: 5, - output: 25, - cacheWrite: 6.25, - cacheRead: 0.5, - }, - - // Claude Opus 4.5 (previous generation) - "claude-opus-4-5": { - input: 5, - output: 25, - cacheWrite: 6.25, - cacheRead: 0.5, - }, - - // Claude Sonnet 4.5 (previous generation) - "claude-sonnet-4-5": { - input: 3, - output: 15, - cacheWrite: 3.75, - cacheRead: 0.3, - }, - - // Claude Haiku 4.5 - "claude-haiku-4-5": { - input: 1, - output: 5, - cacheWrite: 1.25, - cacheRead: 0.1, - }, - - // Claude Opus 4 (legacy) - "claude-3-opus": { - input: 15, - output: 75, - cacheWrite: 18.75, - cacheRead: 1.5, - }, - - // Claude Sonnet 4 (legacy) - "claude-3-5-sonnet": { - input: 3, - output: 15, - cacheWrite: 3.75, - cacheRead: 0.3, - }, - - // Claude Haiku 3.5 (legacy) - "claude-3-5-haiku": { - input: 0.8, - output: 4, - cacheWrite: 1, - cacheRead: 0.08, - }, - - // Claude 3 Haiku (legacy) - "claude-3-haiku": { - input: 0.25, - output: 1.25, - cacheWrite: 0.3, - cacheRead: 0.03, - }, -}; - -const OPENAI_PRICING: PricingTable = { - // GPT-4o models (most specific first) - "gpt-4o-mini": { - input: 0.15, - output: 0.6, - cacheRead: 0.075, - }, - "gpt-4o": { - input: 2.5, - output: 10, - cacheRead: 1.25, - }, - - // GPT-4 Turbo models - "gpt-4-turbo": { input: 10, output: 30 }, - - // GPT-3.5 Turbo models (most specific first) - "gpt-3.5-turbo-0125": { input: 0.5, output: 1.5 }, - "gpt-3.5-turbo-1106": { input: 1, output: 2 }, - "gpt-3.5-turbo": { input: 1.5, output: 2 }, - - // Base GPT-4 (fallback for other gpt-4 variants) - "gpt-4": { input: 30, output: 60 }, -}; - -const DEEPSEEK_PRICING: PricingTable = { - "deepseek-reasoner": { - input: 0.14, - output: 0.28, - cacheRead: 0.0028, - }, - "deepseek-chat": { - input: 0.14, - output: 0.28, - cacheRead: 0.0028, - }, -}; - -const GEMINI_PRICING: PricingTable = { - // Gemini 2.5 Pro - "gemini-2.5-pro": { - input: 1.25, - output: 10, - cacheRead: 0.125, - }, - - // Gemini 2.5 Flash - "gemini-2.5-flash": { - input: 0.3, - output: 2.5, - cacheRead: 0.03, - }, - - // Gemini 2.0 Flash - "gemini-2.0-flash": { - input: 0.1, - output: 0.4, - cacheRead: 0.025, - }, -}; +interface PricingFile { + source: string; + providers: Record; +} -const MISTRAL_PRICING: PricingTable = { - "mistral-large-latest": { input: 0.5, output: 1.5 }, - "mistral-small-latest": { input: 0.15, output: 0.6 }, -}; +const pricingFile = modelPricing as PricingFile; function getPricing(pricing: PricingTable, model: string): ModelPricing | null { // Normalize model name to handle various formats @@ -258,31 +119,15 @@ export function calculateRequestCost( model: string, usage: Usage, ): CostBreakdown | null { - let pricing: PricingTable | null = null; - switch (provider.toLowerCase()) { - case "anthropic": - pricing = ANTHROPIC_PRICING; - break; - case "openai": - pricing = OPENAI_PRICING; - break; - case "deepseek": - pricing = DEEPSEEK_PRICING; - break; - case "gemini": - pricing = GEMINI_PRICING; - break; - case "mistral": - pricing = MISTRAL_PRICING; - break; - default: - return null; + const pricing = pricingFile.providers[provider.toLowerCase()]; + if (!pricing) { + return null; // Unknown provider } - const modelPricing = getPricing(pricing, model); - if (!modelPricing) { + const modelPrice = getPricing(pricing, model); + if (!modelPrice) { return null; // Unknown model } - return calculateCost(model, usage, modelPricing); + return calculateCost(model, usage, modelPrice); } diff --git a/core/llm/utils/modelPricing.json b/core/llm/utils/modelPricing.json new file mode 100644 index 00000000000..f46e0bcd192 --- /dev/null +++ b/core/llm/utils/modelPricing.json @@ -0,0 +1,132 @@ +{ + "source": "Prices are USD per 1M tokens and come from the models.dev API (https://models.opencode.ai/api.json — the same data source used by OpenCode), retrieved 2026-08-08. Keys are model-family prefixes, matched longest-first. Display-only, not billing-authoritative: providers may change prices at any time. Legacy retired models (claude-3-opus, claude-3-5-sonnet, claude-3-5-haiku, claude-3-haiku, gpt-3.5-turbo-0125, gpt-3.5-turbo-1106) are retained from Continue's previous table so existing configurations keep working.", + "providers": { + "anthropic": { + "claude-sonnet-4-6": { + "input": 3, + "output": 15, + "cacheWrite": 3.75, + "cacheRead": 0.3 + }, + "claude-opus-4-6": { + "input": 5, + "output": 25, + "cacheWrite": 6.25, + "cacheRead": 0.5 + }, + "claude-opus-4-5": { + "input": 5, + "output": 25, + "cacheWrite": 6.25, + "cacheRead": 0.5 + }, + "claude-sonnet-4-5": { + "input": 3, + "output": 15, + "cacheWrite": 3.75, + "cacheRead": 0.3 + }, + "claude-haiku-4-5": { + "input": 1, + "output": 5, + "cacheWrite": 1.25, + "cacheRead": 0.1 + }, + "claude-3-opus": { + "input": 15, + "output": 75, + "cacheWrite": 18.75, + "cacheRead": 1.5 + }, + "claude-3-5-sonnet": { + "input": 3, + "output": 15, + "cacheWrite": 3.75, + "cacheRead": 0.3 + }, + "claude-3-5-haiku": { + "input": 0.8, + "output": 4, + "cacheWrite": 1, + "cacheRead": 0.08 + }, + "claude-3-haiku": { + "input": 0.25, + "output": 1.25, + "cacheWrite": 0.3, + "cacheRead": 0.03 + } + }, + "openai": { + "gpt-4o-mini": { + "input": 0.15, + "output": 0.6, + "cacheRead": 0.075 + }, + "gpt-4o": { + "input": 2.5, + "output": 10, + "cacheRead": 1.25 + }, + "gpt-4-turbo": { + "input": 10, + "output": 30 + }, + "gpt-3.5-turbo-0125": { + "input": 0.5, + "output": 1.5 + }, + "gpt-3.5-turbo-1106": { + "input": 1, + "output": 2 + }, + "gpt-3.5-turbo": { + "input": 0.5, + "output": 1.5 + }, + "gpt-4": { + "input": 30, + "output": 60 + } + }, + "deepseek": { + "deepseek-reasoner": { + "input": 0.14, + "output": 0.28, + "cacheRead": 0.0028 + }, + "deepseek-chat": { + "input": 0.14, + "output": 0.28, + "cacheRead": 0.0028 + } + }, + "gemini": { + "gemini-2.5-pro": { + "input": 1.25, + "output": 10, + "cacheRead": 0.125 + }, + "gemini-2.5-flash": { + "input": 0.3, + "output": 2.5, + "cacheRead": 0.03 + }, + "gemini-2.0-flash": { + "input": 0.1, + "output": 0.4, + "cacheRead": 0.025 + } + }, + "mistral": { + "mistral-large-latest": { + "input": 0.5, + "output": 1.5 + }, + "mistral-small-latest": { + "input": 0.15, + "output": 0.6 + } + } + } +}