Merge pull request #10014 from Kilo-Org/fix/kilo-provider-cost-anthropic-messages

fix(cli): extract Kilo provider cost from Anthropic Messages and OpenAI Responses APIs (OpenRouter)
This commit is contained in:
Christiaan Arnoldus
2026-05-08 10:51:01 +02:00
committed by GitHub
5 changed files with 245 additions and 19 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Improved accuracy of Kilo Gateway cost reporting.
+65 -19
View File
@@ -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
}
+1
View File
@@ -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,
})
@@ -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)
})
})
@@ -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"])(