From 3423870eea9723dcdab02cc8e061cd3585e19bc2 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 13:07:52 +0000 Subject: [PATCH] fix(cli): extract Kilo provider cost from Anthropic Messages API responses Cost reporting already worked when Kilo used OpenRouter chat completions internally. Extend providerCost to also read cost from the Anthropic Messages API stream metadata, covering both OpenRouter (Anthropic-style `usage.cost` / `cost_details.upstream_inference_cost`) and Vercel AI Gateway (`gateway.cost` / `gateway.marketCost`). --- .changeset/kilo-anthropic-messages-cost.md | 5 + .../opencode/src/kilocode/session/index.ts | 65 ++++++-- .../opencode/test/session/compaction.test.ts | 149 ++++++++++++++++++ 3 files changed, 202 insertions(+), 17 deletions(-) create mode 100644 .changeset/kilo-anthropic-messages-cost.md 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"])(