mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-10 07:11:25 +03:00
feat(ai): report GitHub Copilot billing as usage cost
This commit is contained in:
@@ -1032,7 +1032,11 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
// inclusive `inputTokens` the rest of the contract expects. Extended
|
||||
// thinking tokens are included in `output_tokens`; newer responses also
|
||||
// expose that subset through `output_tokens_details.thinking_tokens`.
|
||||
const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string): Usage | undefined => {
|
||||
const mapUsage = (
|
||||
usage: AnthropicUsage | undefined,
|
||||
providerMetadataKey: string,
|
||||
frame?: AnthropicEvent,
|
||||
): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const iterations = usage.iterations?.length ? usage.iterations : [usage]
|
||||
const last = usage.iterations?.at(-1)
|
||||
@@ -1059,7 +1063,7 @@ const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens_details?.thinking_tokens)),
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
||||
...ProviderShared.reportedBilling(usage),
|
||||
...ProviderShared.reportedBilling(usage, frame),
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
})
|
||||
}
|
||||
@@ -1331,7 +1335,11 @@ const onMessageDelta = (
|
||||
state: ParserState,
|
||||
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
|
||||
): StepResult => {
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey)
|
||||
const usage = mergeUsage(
|
||||
state.usage,
|
||||
mapUsage(event.usage, state.providerMetadataKey, event),
|
||||
state.providerMetadataKey,
|
||||
)
|
||||
const pendingFinish = (() => {
|
||||
const stopReason = event.delta?.stop_reason
|
||||
if (stopReason === null || stopReason === undefined) return state.pendingFinish
|
||||
|
||||
@@ -890,7 +890,11 @@ export const fromRequest = (request: LLMRequest) => fromRequestWithAdapter(reque
|
||||
// cached-read and cache-write subsets, and `output_tokens` (inclusive total)
|
||||
// with a `reasoning_tokens` subset. Pass the totals through and derive the
|
||||
// non-cached breakdown.
|
||||
export const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
|
||||
export const mapUsage = (
|
||||
usage: OpenResponsesUsage | null | undefined,
|
||||
providerMetadataKey: string,
|
||||
...frames: ReadonlyArray<unknown>
|
||||
) => {
|
||||
if (!usage) return undefined
|
||||
const cached = usage.input_tokens_details?.cached_tokens
|
||||
const cacheWrite = usage.input_tokens_details?.cache_write_tokens
|
||||
@@ -904,7 +908,7 @@ export const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerM
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: reasoning,
|
||||
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens),
|
||||
...ProviderShared.reportedBilling(usage),
|
||||
...ProviderShared.reportedBilling(usage, ...frames),
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
})
|
||||
}
|
||||
@@ -1397,7 +1401,8 @@ const onResponseFinish = Effect.fnUntraced(function* (state: ParserState, event:
|
||||
normalized: mapFinishReason(event, current.hasFunctionCall),
|
||||
raw: event.response?.incomplete_details?.reason,
|
||||
},
|
||||
usage: mapUsage(event.response?.usage, current.providerMetadataKey),
|
||||
// Copilot reports its billing on the terminal event, beside `response`.
|
||||
usage: mapUsage(event.response?.usage, current.providerMetadataKey, event, event.response),
|
||||
providerMetadata:
|
||||
event.response?.id || event.response?.service_tier
|
||||
? providerMetadata(current, {
|
||||
|
||||
@@ -960,7 +960,11 @@ const mapFinishReason = Effect.fnUntraced(function* (event: OpenAIChatEvent, rea
|
||||
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
|
||||
// `prompt_cache_hit_tokens`, Zai uses top-level `cached_tokens`, and
|
||||
// DigitalOcean uses top-level `cache_read_input_tokens` / `cache_created_input_tokens`.
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"], providerMetadataKey: string): Usage | undefined => {
|
||||
const mapUsage = (
|
||||
usage: OpenAIChatEvent["usage"],
|
||||
providerMetadataKey: string,
|
||||
frame: OpenAIChatEvent,
|
||||
): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const input = usage.prompt_tokens ?? undefined
|
||||
const output = usage.completion_tokens ?? undefined
|
||||
@@ -981,7 +985,7 @@ const mapUsage = (usage: OpenAIChatEvent["usage"], providerMetadataKey: string):
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: reasoning,
|
||||
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
|
||||
...ProviderShared.reportedBilling(usage),
|
||||
...ProviderShared.reportedBilling(usage, frame),
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
})
|
||||
}
|
||||
@@ -1116,8 +1120,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
|
||||
// `choice.usage` instead of the top-level `usage` field.
|
||||
const usage =
|
||||
mapUsage(event.usage, state.providerMetadataKey) ??
|
||||
mapUsage(choice?.usage, state.providerMetadataKey) ??
|
||||
mapUsage(event.usage, state.providerMetadataKey, event) ??
|
||||
mapUsage(choice?.usage, state.providerMetadataKey, event) ??
|
||||
state.usage
|
||||
const rawFinishReason = choice?.finish_reason
|
||||
const finishReason = rawFinishReason
|
||||
|
||||
@@ -54,12 +54,27 @@ export const ReportedBilling = Schema.Struct({
|
||||
cost: billedAmount,
|
||||
})
|
||||
|
||||
/** Reads `ReportedBilling` from a raw usage object, which provider metadata keeps as the endpoint sent it. */
|
||||
export const reportedBilling = (usage: unknown) =>
|
||||
Option.match(Schema.decodeUnknownOption(ReportedBilling)(usage), {
|
||||
onNone: () => ({}),
|
||||
onSome: (billing) => ({ cost: billing.cost }),
|
||||
})
|
||||
/**
|
||||
* GitHub Copilot reports its charge beside usage on the frame that carries it, as `copilot_usage.total_nano_aiu`:
|
||||
* billionths of an AI credit, which costs $0.01.
|
||||
*/
|
||||
const CopilotBilling = Schema.Struct({
|
||||
copilot_usage: Schema.Struct({ total_nano_aiu: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) }),
|
||||
})
|
||||
const decodeCopilotBilling = Schema.decodeUnknownOption(CopilotBilling)
|
||||
|
||||
/**
|
||||
* Reads `ReportedBilling` from a raw usage object, which provider metadata keeps as the endpoint sent it, or
|
||||
* Copilot's billing from the frames that carry the usage.
|
||||
*/
|
||||
export const reportedBilling = (usage: unknown, ...frames: ReadonlyArray<unknown>) => {
|
||||
const reported = Option.getOrUndefined(Schema.decodeUnknownOption(ReportedBilling)(usage))?.cost
|
||||
if (reported !== undefined) return { cost: reported }
|
||||
const nanoAiu = frames
|
||||
.map((frame) => Option.getOrUndefined(decodeCopilotBilling(frame))?.copilot_usage.total_nano_aiu)
|
||||
.find((value) => value !== undefined)
|
||||
return nanoAiu === undefined ? {} : { cost: nanoAiu / 100_000_000_000 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts stream frames that carry no `type`, alongside the typed event schema. Gateways inject such frames as
|
||||
|
||||
@@ -1360,6 +1360,29 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("converts the GitHub Copilot billing on the message delta to USD", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn" },
|
||||
usage: { output_tokens: 2 },
|
||||
copilot_usage: { total_nano_aiu: 4_473_525_000 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, cost: 0.04473525 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves terminal state across usage-only message deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -1264,6 +1264,23 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("converts the GitHub Copilot billing beside usage to USD", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(deltaChunk({ content: "Hello" }, "stop"), {
|
||||
...usageChunk({ prompt_tokens: 5, completion_tokens: 2 }),
|
||||
copilot_usage: { total_nano_aiu: 4_473_525_000, token_details: [] },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, cost: 0.04473525 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops a malformed billed cost without failing the stream", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -400,6 +400,25 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("converts the GitHub Copilot billing on the completed event to USD", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({ apiKey: "test-key", baseURL: "https://api.githubcopilot.test" }).model("gpt-5")
|
||||
const response = yield* LLMClient.generate(ai.request({ model, prompt: "Say hello." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 } },
|
||||
copilot_usage: { total_nano_aiu: 4_473_525_000 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, cost: 0.04473525 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores bare null frames between events", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
|
||||
@@ -19,7 +19,6 @@ export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
|
||||
})
|
||||
|
||||
// Catalog pricing is the fallback for endpoints that don't report what they charged (`Usage.cost`).
|
||||
// TODO(#35765): Normalize Copilot's reported billed amount into `Usage.cost`.
|
||||
export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info) {
|
||||
const context = usage.input + usage.cache.read + usage.cache.write
|
||||
const tier = costs
|
||||
|
||||
Reference in New Issue
Block a user