diff --git a/.changeset/kilo-anthropic-messages-cost.md b/.changeset/kilo-anthropic-messages-cost.md new file mode 100644 index 00000000000..e6e10e3f7ab --- /dev/null +++ b/.changeset/kilo-anthropic-messages-cost.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Report accurate Kilo provider costs for models served through the Anthropic Messages API (via OpenRouter or Vercel AI Gateway). diff --git a/packages/opencode/src/kilocode/session/index.ts b/packages/opencode/src/kilocode/session/index.ts index b77f3b0d658..6322adde6a1 100644 --- a/packages/opencode/src/kilocode/session/index.ts +++ b/packages/opencode/src/kilocode/session/index.ts @@ -101,9 +101,18 @@ export namespace KiloSession { // --------------------------------------------------------------------------- /** - * Extract provider-reported cost from OpenRouter metadata when available. - * For the Kilo provider (BYOK), prefers `upstreamInferenceCost` over the - * regular `cost` field (which is just the OpenRouter 5% fee). + * Extract provider-reported cost from response metadata when available. + * + * Supports three internal transports: + * 1. OpenRouter chat completions -> `metadata.openrouter.usage.cost` + * (`costDetails.upstreamInferenceCost` for BYOK) + * 2. Anthropic Messages via OpenRouter -> `metadata.anthropic.usage.cost` + * (`cost_details.upstream_inference_cost` for BYOK) + * 3. Anthropic Messages via Vercel AI Gateway -> `metadata.gateway.cost` + * (`metadata.gateway.marketCost` for BYOK) + * + * For the Kilo provider (always BYOK), prefers the upstream/market cost over + * the regular `cost` field (which represents the gateway fee, often 0). * * Returns `undefined` when no provider cost is available, so the caller * should fall back to the standard token-based calculation. @@ -115,23 +124,45 @@ export namespace KiloSession { provider?: Provider.Info providerID: string }): number | undefined { - const openrouterUsage = input.metadata?.["openrouter"]?.["usage"] as - | { - cost?: number - costDetails?: { upstreamInferenceCost?: number } - } - | undefined - - if (!openrouterUsage) return undefined - const isKilo = (input.provider?.id ?? input.providerID) === "kilo" - const upstream = openrouterUsage.costDetails?.upstreamInferenceCost - const regular = openrouterUsage.cost - // Kilo is always BYOK, so prefer upstream cost. For OpenRouter, use regular cost. - const cost = isKilo && upstream !== undefined ? upstream : regular + const num = (value: unknown): number | undefined => { + if (value === undefined || value === null) return undefined + const n = typeof value === "string" ? Number(value) : (value as number) + return Number.isFinite(n) ? n : undefined + } + + const pick = (regular: unknown, upstream: unknown): number | undefined => { + const u = num(upstream) + const r = num(regular) + return isKilo && u !== undefined ? u : (r ?? u) + } + + // 1. OpenRouter chat completions + const orUsage = input.metadata?.["openrouter"]?.["usage"] as + | { cost?: number; costDetails?: { upstreamInferenceCost?: number } } + | undefined + if (orUsage) { + const cost = pick(orUsage.cost, orUsage.costDetails?.upstreamInferenceCost) + if (cost !== undefined) return cost + } + + // 2. Anthropic Messages API (passes through OpenRouter `usage` fields verbatim) + const anthropicUsage = input.metadata?.["anthropic"]?.["usage"] as + | { cost?: number; cost_details?: { upstream_inference_cost?: number } } + | undefined + if (anthropicUsage) { + const cost = pick(anthropicUsage.cost, anthropicUsage.cost_details?.upstream_inference_cost) + if (cost !== undefined) return cost + } + + // 3. Vercel AI Gateway (cost / marketCost are emitted as strings) + const gateway = input.metadata?.["gateway"] as { cost?: string | number; marketCost?: string | number } | undefined + if (gateway) { + const cost = pick(gateway.cost, gateway.marketCost) + if (cost !== undefined) return cost + } - if (cost !== undefined && cost !== null && Number.isFinite(cost)) return cost return undefined } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index c7b0b21de04..dbed0730c7d 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -2329,6 +2329,155 @@ describe("SessionNs.getUsage", () => { // When upstream cost is missing for Kilo, fall back to regular cost field expect(result.cost).toBe(0.01) }) + + test("uses anthropic messages api cost for OpenRouter (Kilo BYOK)", () => { + const model = createModel({ + context: 100_000, + output: 32_000, + cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } }, + }) + const provider = { id: "kilo" } as Provider.Info + const result = SessionNs.getUsage({ + model, + provider, + usage: { + inputTokens: 1_000_000, + outputTokens: 100_000, + totalTokens: 1_100_000, + inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, + }, + metadata: { + anthropic: { + usage: { + cost: 0.0057550875, + is_byok: true, + cost_details: { + upstream_inference_cost: 0.11510175, + }, + }, + }, + }, + }) + + expect(result.cost).toBe(0.11510175) + }) + + test("uses anthropic messages api cost for OpenRouter (non-BYOK)", () => { + const model = createModel({ + context: 100_000, + output: 32_000, + cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } }, + }) + const provider = { id: "openrouter" } as Provider.Info + const result = SessionNs.getUsage({ + model, + provider, + usage: { + inputTokens: 1_000_000, + outputTokens: 100_000, + totalTokens: 1_100_000, + inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, + }, + metadata: { + anthropic: { + usage: { + cost: 0.5, + cost_details: { upstream_inference_cost: 0.45 }, + }, + }, + }, + }) + + expect(result.cost).toBe(0.5) + }) + + test("uses Vercel AI Gateway marketCost for Kilo provider", () => { + const model = createModel({ + context: 100_000, + output: 32_000, + cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } }, + }) + const provider = { id: "kilo" } as Provider.Info + const result = SessionNs.getUsage({ + model, + provider, + usage: { + inputTokens: 1_000_000, + outputTokens: 100_000, + totalTokens: 1_100_000, + inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, + }, + metadata: { + gateway: { + // Strings, exactly as emitted by the AI Gateway message_delta event + cost: "0", + marketCost: "0.35349075", + }, + }, + }) + + expect(result.cost).toBe(0.35349075) + }) + + test("uses Vercel AI Gateway cost when marketCost missing", () => { + const model = createModel({ + context: 100_000, + output: 32_000, + cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } }, + }) + const provider = { id: "kilo" } as Provider.Info + const result = SessionNs.getUsage({ + model, + provider, + usage: { + inputTokens: 1_000_000, + outputTokens: 100_000, + totalTokens: 1_100_000, + inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, + }, + metadata: { + gateway: { + cost: "0.123", + }, + }, + }) + + expect(result.cost).toBe(0.123) + }) + + test("falls back to calculated cost when no provider cost is reported", () => { + const model = createModel({ + context: 100_000, + output: 32_000, + cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } }, + }) + const provider = { id: "kilo" } as Provider.Info + const result = SessionNs.getUsage({ + model, + provider, + usage: { + inputTokens: 1_000_000, + outputTokens: 100_000, + totalTokens: 1_100_000, + inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, + }, + metadata: { + anthropic: { + usage: { + // no cost / cost_details + }, + }, + }, + }) + + // 1M input * $3 + 100k output * $15 = 3 + 1.5 + expect(result.cost).toBe(3 + 1.5) + }) // kilocode_change end test.each(["@ai-sdk/anthropic", "@ai-sdk/amazon-bedrock", "@ai-sdk/google-vertex/anthropic"])(