diff --git a/.changeset/kilo-gateway-cost-accuracy.md b/.changeset/kilo-gateway-cost-accuracy.md new file mode 100644 index 00000000000..35cd2cd89d6 --- /dev/null +++ b/.changeset/kilo-gateway-cost-accuracy.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Improved accuracy of Kilo Gateway cost reporting. diff --git a/packages/opencode/src/kilocode/session/index.ts b/packages/opencode/src/kilocode/session/index.ts index b77f3b0d658..9e56cab0fbf 100644 --- a/packages/opencode/src/kilocode/session/index.ts +++ b/packages/opencode/src/kilocode/session/index.ts @@ -14,7 +14,7 @@ import { ProjectID } from "@/project/schema" import { Filesystem } from "@/util/filesystem" import { SessionTable } from "@/session/session.sql" import * as Log from "@opencode-ai/core/util/log" -import type { ProviderMetadata } from "ai" +import type { LanguageModelUsage, ProviderMetadata } from "ai" import type { Provider } from "@/provider/provider" export namespace KiloSession { @@ -97,13 +97,28 @@ export namespace KiloSession { } // --------------------------------------------------------------------------- - // Provider-reported cost (OpenRouter / Kilo) + // Provider-reported cost (Kilo / OpenRouter / Vercel AI Gateway) // --------------------------------------------------------------------------- /** - * 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 the following internal transports: + * 1. OpenRouter chat completions -> `metadata.openrouter.usage.cost` + * (`costDetails.upstreamInferenceCost` for Kilo) + * 2. Anthropic Messages or OpenAI Responses via OpenRouter + * -> `usage.raw.cost_details.upstream_inference_cost` + * (the `@ai-sdk/anthropic` and `@ai-sdk/openai` providers both surface the verbatim + * provider usage object on `LanguageModelUsage.raw`, so OpenRouter's upstream + * inference cost lands there with snake_case preserved) + * 3. Anthropic Messages or OpenAI Responses via Vercel AI Gateway + * -> `metadata.gateway.marketCost` (defensive: the + * gateway emits this in the SSE `provider_metadata` field, which the current AI SDK + * providers drop before they reach this layer) + * + * Kilo does not charge end users a per-request fee, so for the Kilo provider the + * top-level `cost` field (the gateway/marketplace fee) would understate the user's + * actual upstream spend. Always prefer the upstream/market cost when present. * * Returns `undefined` when no provider cost is available, so the caller * should fall back to the standard token-based calculation. @@ -112,26 +127,57 @@ export namespace KiloSession { */ export function providerCost(input: { metadata?: ProviderMetadata + usage?: LanguageModelUsage 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 + } + + // 1. OpenRouter chat completions + const orUsage = input.metadata?.["openrouter"]?.["usage"] as + | { cost?: number; costDetails?: { upstreamInferenceCost?: number } } + | undefined + if (orUsage) { + const upstream = num(orUsage.costDetails?.upstreamInferenceCost) + const regular = num(orUsage.cost) + // Kilo doesn't charge a fee on top of the upstream inference cost, so for Kilo + // prefer the upstream cost (the user's true spend). For the OpenRouter provider + // itself, the regular `cost` field is what the user is billed. + const cost = isKilo && upstream !== undefined ? upstream : regular + if (cost !== undefined) return cost + } + + // 2. Anthropic Messages or OpenAI Responses via OpenRouter. The `@ai-sdk/anthropic` + // (`convertAnthropicUsage`) and `@ai-sdk/openai` (`convertOpenAIResponsesUsage`) + // providers both copy the verbatim provider usage object onto `usage.raw`, so + // OpenRouter's upstream inference cost lands at + // `usage.raw.cost_details.upstream_inference_cost` with snake_case preserved. + // Kilo doesn't charge end users a per-request fee, so the top-level `cost` field + // (the OpenRouter fee) would understate the user's true spend; only the upstream + // cost is meaningful here. + const raw = input.usage?.raw as { cost_details?: { upstream_inference_cost?: number } } | undefined + const upstream = num(raw?.cost_details?.upstream_inference_cost) + if (upstream !== undefined) return upstream + + // 3. Anthropic Messages or OpenAI Responses via Vercel AI Gateway. `cost` is the + // gateway fee that Kilo would pass through, but Kilo doesn't charge end users a + // per-request fee, so always use `marketCost` (the upstream provider's price). + // Values are emitted as strings on the wire. + // + // NOTE: this branch is currently dormant because neither `@ai-sdk/anthropic` nor + // `@ai-sdk/openai` (responses) forwards the SSE-level `provider_metadata.gateway` + // block to `providerMetadata`. Kept as defensive code so the cost starts flowing + // automatically once the SDK gap is closed. + const gateway = input.metadata?.["gateway"] as { marketCost?: string | number } | undefined + const marketCost = num(gateway?.marketCost) + if (marketCost !== undefined) return marketCost - if (cost !== undefined && cost !== null && Number.isFinite(cost)) return cost return undefined } diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 2b9f644e15e..89e7ddeb7c4 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -385,6 +385,7 @@ export const getUsage = (input: { // kilocode_change start - Use provider-reported cost when available for OpenRouter/Kilo const reported = KiloSession.providerCost({ metadata: input.metadata, + usage: input.usage, provider: input.provider, providerID: input.model.providerID, }) diff --git a/packages/opencode/test/kilocode/provider-cost.test.ts b/packages/opencode/test/kilocode/provider-cost.test.ts new file mode 100644 index 00000000000..fbace3f1c73 --- /dev/null +++ b/packages/opencode/test/kilocode/provider-cost.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "bun:test" +import { Session as SessionNs } from "@/session/session" +import type { Provider } from "@/provider/provider" + +function createModel(opts: { + context: number + output: number + input?: number + cost?: Provider.Model["cost"] + npm?: string +}): Provider.Model { + return { + id: "test-model", + providerID: "test", + name: "Test", + limit: { + context: opts.context, + input: opts.input, + output: opts.output, + }, + cost: opts.cost ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, + capabilities: { + toolcall: true, + attachment: false, + reasoning: false, + temperature: true, + input: { text: true, image: false, audio: false, video: false }, + output: { text: true, image: false, audio: false, video: false }, + }, + api: { npm: opts.npm ?? "@ai-sdk/anthropic" }, + options: {}, + } as Provider.Model +} + +const baseUsage = { + inputTokens: 1_000_000, + outputTokens: 100_000, + totalTokens: 1_100_000, + inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, +} + +const model = () => + createModel({ + context: 100_000, + output: 32_000, + cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } }, + }) + +const kilo = { id: "kilo" } as Provider.Info + +// Calculated cost for the `model()` + `baseUsage` pair: 1M input * $3 + 100k output * $15 = 3 + 1.5 +const fallback = 3 + 1.5 + +describe("KiloSession.providerCost — Anthropic Messages / OpenAI Responses", () => { + test("uses usage.raw.cost_details.upstream_inference_cost for Anthropic Messages via OpenRouter", () => { + const result = SessionNs.getUsage({ + model: model(), + provider: kilo, + usage: { + ...baseUsage, + // `convertAnthropicUsage` copies the verbatim provider usage onto `raw`. + // Top-level `cost` is the OpenRouter fee and must be ignored. + raw: { + input_tokens: 1, + output_tokens: 1121, + cache_creation_input_tokens: 5385, + cache_read_input_tokens: 106831, + cost: 0.0057550875, + is_byok: true, + cost_details: { + upstream_inference_cost: 0.11510175, + }, + }, + }, + }) + + expect(result.cost).toBe(0.11510175) + }) + + test("uses usage.raw.cost_details.upstream_inference_cost for OpenAI Responses via OpenRouter", () => { + const result = SessionNs.getUsage({ + model: model(), + provider: kilo, + usage: { + ...baseUsage, + // `convertOpenAIResponsesUsage` copies the verbatim provider usage onto `raw`. + raw: { + input_tokens: 622051, + input_tokens_details: { cached_tokens: 594944 }, + output_tokens: 304, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 622355, + cost: 0.0439847, + is_byok: true, + cost_details: { + upstream_inference_cost: 0.879694, + upstream_inference_input_cost: 0.866014, + upstream_inference_output_cost: 0.01368, + }, + }, + }, + }) + + expect(result.cost).toBe(0.879694) + }) + + test("ignores raw `cost` when no upstream_inference_cost is reported", () => { + const result = SessionNs.getUsage({ + model: model(), + provider: kilo, + usage: { + ...baseUsage, + raw: { + cost: 0.5, + // cost_details missing + }, + }, + }) + + expect(result.cost).toBe(fallback) + }) +}) + +describe("KiloSession.providerCost — Vercel AI Gateway", () => { + test("uses metadata.gateway.marketCost", () => { + const result = SessionNs.getUsage({ + model: model(), + provider: kilo, + usage: baseUsage, + metadata: { + gateway: { + // Strings, exactly as emitted by the AI Gateway. `cost` is the gateway fee, + // which Kilo doesn't pass on to end users — must be ignored. + cost: "0", + marketCost: "0.35349075", + }, + }, + }) + + expect(result.cost).toBe(0.35349075) + }) + + test("ignores metadata.gateway.cost when marketCost is missing", () => { + const result = SessionNs.getUsage({ + model: model(), + provider: kilo, + usage: baseUsage, + metadata: { + gateway: { + cost: "0.123", + }, + }, + }) + + expect(result.cost).toBe(fallback) + }) +}) + +describe("KiloSession.providerCost — fallback", () => { + test("falls back to calculated cost when no provider cost is reported", () => { + const result = SessionNs.getUsage({ + model: model(), + provider: kilo, + usage: baseUsage, + // No metadata, no usage.raw — should fall back + }) + + expect(result.cost).toBe(fallback) + }) +}) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index c7b0b21de04..9243bdea5be 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -2329,6 +2329,9 @@ describe("SessionNs.getUsage", () => { // When upstream cost is missing for Kilo, fall back to regular cost field expect(result.cost).toBe(0.01) }) + + // Tests for Anthropic Messages / OpenAI Responses / Vercel AI Gateway cost extraction + // live in test/kilocode/provider-cost.test.ts (kilocode_change). // kilocode_change end test.each(["@ai-sdk/anthropic", "@ai-sdk/amazon-bedrock", "@ai-sdk/google-vertex/anthropic"])(