Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,16 +335,44 @@ export function plan(input: { slug: string; time: { created: number } }, instanc
return path.join(base, [input.time.created, input.slug].join("-") + ".md")
}

function metadataNumber(value: unknown): number | undefined {
if (typeof value !== "number") return undefined
return Number.isFinite(value) && value >= 0 ? value : undefined
}

function metadataRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
return value as Record<string, unknown>
}

function metadataUsage(value: ProviderMetadata | undefined) {
if (!value) return
const roots = Object.values(value)
.map(metadataRecord)
.filter((item): item is Record<string, unknown> => !!item)
if (roots.length === 0) return
const usage = roots.map((item) => metadataRecord(item.usage)).find((item): item is Record<string, unknown> => !!item)
return usage ?? roots[0]
}

export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata?: ProviderMetadata }) => {
const safe = (value: number) => {
if (!Number.isFinite(value)) return 0
return Math.max(0, value)
}
const metadata = metadataUsage(input.metadata)
const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0)
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)

const cacheReadInputTokens = safe(input.usage.cacheReadInputTokens ?? 0)
const cacheReadInputTokens = safe(
Number(
input.usage.cacheReadInputTokens ??
metadataNumber(metadataRecord(metadata?.prompt_tokens_details)?.cached_tokens) ??
metadataNumber(metadata?.cached_tokens) ??
0,
),
)
const cacheWriteInputTokens = safe(
Number(
input.usage.cacheWriteInputTokens ??
Expand All @@ -356,6 +384,8 @@ export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata?
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
// @ts-expect-error
input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
metadataNumber(metadata?.cache_creation_input_tokens) ??
metadataNumber(metadata?.cacheWriteInputTokens) ??
0,
),
)
Expand Down
100 changes: 100 additions & 0 deletions packages/opencode/test/session/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,106 @@ describe("session.llm.ai-sdk adapter", () => {
expect(result.tokens.cache.read).toBe(200)
})

test("extracts cache read tokens from openai-compatible metadata usage fallback", async () => {
const result = SessionNs.getUsage({
model: {
id: "custom-gpt",
providerID: "custom-provider",
name: "Custom GPT",
limit: { context: 200_000, output: 8_000 },
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: "@ai-sdk/openai-compatible" },
options: {},
} as never,
usage: { inputTokens: 1000, outputTokens: 200, totalTokens: 1200 } as never,
metadata: {
custom: {
usage: {
prompt_tokens_details: {
cached_tokens: 450,
},
},
},
},
})

expect(result.tokens.cache.read).toBe(450)
})

test("extracts cache write tokens from openai-compatible metadata usage", async () => {
const result = SessionNs.getUsage({
model: {
id: "custom-gpt",
providerID: "custom-provider",
name: "Custom GPT",
limit: { context: 200_000, output: 8_000 },
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: "@ai-sdk/openai-compatible" },
options: {},
} as never,
usage: { inputTokens: 1000, outputTokens: 200, totalTokens: 1200 } as never,
metadata: {
custom: {
usage: {
cache_creation_input_tokens: 200,
},
},
},
})

expect(result.tokens.cache.write).toBe(200)
})

test("respects usage.cacheReadInputTokens over metadata", async () => {
const result = SessionNs.getUsage({
model: {
id: "custom-gpt",
providerID: "custom-provider",
name: "Custom GPT",
limit: { context: 200_000, output: 8_000 },
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: "@ai-sdk/openai-compatible" },
options: {},
} as never,
usage: { inputTokens: 1000, outputTokens: 200, totalTokens: 1200, cacheReadInputTokens: 100 } as never,
metadata: {
custom: {
usage: {
prompt_tokens_details: {
cached_tokens: 450,
},
},
},
},
})

expect(result.tokens.cache.read).toBe(100)
})

test("captures Copilot billed usage from raw Anthropic message deltas per step", async () => {
const events = await adapt([
uncheckedAdapterEvent({
Expand Down
Loading