mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 14:07:20 +08:00
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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Improved accuracy of Kilo Gateway cost reporting.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"])(
|
||||
|
||||
Reference in New Issue
Block a user