fix: stabilize token/cache accounting across providers and routed Roo metadata (#11448)

This commit is contained in:
Hannes Rudolph 2026-02-12 16:10:43 -07:00 committed by GitHub
parent b7857bcd6a
commit 3965cd9743
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1267 additions and 157 deletions

View file

@ -1279,4 +1279,165 @@ describe("AwsBedrockHandler", () => {
expect(mockCaptureException).toHaveBeenCalled()
})
})
describe("AI SDK v6 usage field paths", () => {
const systemPrompt = "You are a helpful assistant"
const messages: RooMessage[] = [
{
role: "user",
content: "Hello",
},
]
function setupStream(usage: Record<string, unknown>, providerMetadata: Record<string, unknown> = {}) {
async function* mockFullStream() {
yield { type: "text-delta", text: "reply" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve(usage),
providerMetadata: Promise.resolve(providerMetadata),
})
}
describe("cache tokens", () => {
it("should read cache tokens from v6 top-level cachedInputTokens", async () => {
setupStream({ inputTokens: 100, outputTokens: 50, cachedInputTokens: 30 })
const generator = handler.createMessage(systemPrompt, messages)
const chunks: unknown[] = []
for await (const chunk of generator) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c: any) => c.type === "usage") as any
expect(usageChunk).toBeDefined()
expect(usageChunk.cacheReadTokens).toBe(30)
})
it("should read cache tokens from v6 inputTokenDetails.cacheReadTokens", async () => {
setupStream({
inputTokens: 100,
outputTokens: 50,
inputTokenDetails: { cacheReadTokens: 25 },
})
const generator = handler.createMessage(systemPrompt, messages)
const chunks: unknown[] = []
for await (const chunk of generator) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c: any) => c.type === "usage") as any
expect(usageChunk).toBeDefined()
expect(usageChunk.cacheReadTokens).toBe(25)
})
it("should prefer v6 top-level cachedInputTokens over providerMetadata.bedrock", async () => {
setupStream(
{ inputTokens: 100, outputTokens: 50, cachedInputTokens: 30 },
{ bedrock: { usage: { cacheReadInputTokens: 20 } } },
)
const generator = handler.createMessage(systemPrompt, messages)
const chunks: unknown[] = []
for await (const chunk of generator) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c: any) => c.type === "usage") as any
expect(usageChunk).toBeDefined()
expect(usageChunk.cacheReadTokens).toBe(30)
})
it("should fall back to providerMetadata.bedrock.usage.cacheReadInputTokens", async () => {
setupStream(
{ inputTokens: 100, outputTokens: 50 },
{ bedrock: { usage: { cacheReadInputTokens: 20 } } },
)
const generator = handler.createMessage(systemPrompt, messages)
const chunks: unknown[] = []
for await (const chunk of generator) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c: any) => c.type === "usage") as any
expect(usageChunk).toBeDefined()
expect(usageChunk.cacheReadTokens).toBe(20)
})
it("should read cacheWriteTokens from v6 inputTokenDetails.cacheWriteTokens", async () => {
setupStream({
inputTokens: 100,
outputTokens: 50,
inputTokenDetails: { cacheWriteTokens: 15 },
})
const generator = handler.createMessage(systemPrompt, messages)
const chunks: unknown[] = []
for await (const chunk of generator) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c: any) => c.type === "usage") as any
expect(usageChunk).toBeDefined()
expect(usageChunk.cacheWriteTokens).toBe(15)
})
})
describe("reasoning tokens", () => {
it("should read reasoning tokens from v6 top-level reasoningTokens", async () => {
setupStream({ inputTokens: 100, outputTokens: 50, reasoningTokens: 40 })
const generator = handler.createMessage(systemPrompt, messages)
const chunks: unknown[] = []
for await (const chunk of generator) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c: any) => c.type === "usage") as any
expect(usageChunk).toBeDefined()
expect(usageChunk.reasoningTokens).toBe(40)
})
it("should read reasoning tokens from v6 outputTokenDetails.reasoningTokens", async () => {
setupStream({
inputTokens: 100,
outputTokens: 50,
outputTokenDetails: { reasoningTokens: 35 },
})
const generator = handler.createMessage(systemPrompt, messages)
const chunks: unknown[] = []
for await (const chunk of generator) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c: any) => c.type === "usage") as any
expect(usageChunk).toBeDefined()
expect(usageChunk.reasoningTokens).toBe(35)
})
it("should prefer v6 top-level reasoningTokens over outputTokenDetails", async () => {
setupStream({
inputTokens: 100,
outputTokens: 50,
reasoningTokens: 40,
outputTokenDetails: { reasoningTokens: 15 },
})
const generator = handler.createMessage(systemPrompt, messages)
const chunks: unknown[] = []
for await (const chunk of generator) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c: any) => c.type === "usage") as any
expect(usageChunk).toBeDefined()
expect(usageChunk.reasoningTokens).toBe(40)
})
})
})
})

View file

@ -472,4 +472,168 @@ describe("GeminiHandler", () => {
expect(mockCaptureException).toHaveBeenCalled()
})
})
describe("AI SDK v6 usage field paths", () => {
const mockMessages: RooMessage[] = [
{
role: "user",
content: "Hello",
},
]
const systemPrompt = "You are a helpful assistant"
function setupStream(usage: Record<string, unknown>) {
const mockFullStream = (async function* () {
yield { type: "text-delta", text: "reply" }
})()
mockStreamText.mockReturnValue({
fullStream: mockFullStream,
usage: Promise.resolve(usage),
providerMetadata: Promise.resolve({}),
})
}
describe("cache tokens", () => {
it("should read cache tokens from v6 top-level cachedInputTokens", async () => {
setupStream({ inputTokens: 100, outputTokens: 50, cachedInputTokens: 30 })
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toBeDefined()
expect(usageChunk!.cacheReadTokens).toBe(30)
})
it("should read cache tokens from v6 inputTokenDetails.cacheReadTokens", async () => {
setupStream({
inputTokens: 100,
outputTokens: 50,
inputTokenDetails: { cacheReadTokens: 25 },
})
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toBeDefined()
expect(usageChunk!.cacheReadTokens).toBe(25)
})
it("should prefer v6 top-level cachedInputTokens over legacy details", async () => {
setupStream({
inputTokens: 100,
outputTokens: 50,
cachedInputTokens: 30,
details: { cachedInputTokens: 20 },
})
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toBeDefined()
expect(usageChunk!.cacheReadTokens).toBe(30)
})
it("should fall back to legacy details.cachedInputTokens", async () => {
setupStream({
inputTokens: 100,
outputTokens: 50,
details: { cachedInputTokens: 20 },
})
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toBeDefined()
expect(usageChunk!.cacheReadTokens).toBe(20)
})
})
describe("reasoning tokens", () => {
it("should read reasoning tokens from v6 top-level reasoningTokens", async () => {
setupStream({ inputTokens: 100, outputTokens: 50, reasoningTokens: 40 })
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toBeDefined()
expect(usageChunk!.reasoningTokens).toBe(40)
})
it("should read reasoning tokens from v6 outputTokenDetails.reasoningTokens", async () => {
setupStream({
inputTokens: 100,
outputTokens: 50,
outputTokenDetails: { reasoningTokens: 35 },
})
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toBeDefined()
expect(usageChunk!.reasoningTokens).toBe(35)
})
it("should prefer v6 top-level reasoningTokens over legacy details", async () => {
setupStream({
inputTokens: 100,
outputTokens: 50,
reasoningTokens: 40,
details: { reasoningTokens: 15 },
})
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toBeDefined()
expect(usageChunk!.reasoningTokens).toBe(40)
})
it("should fall back to legacy details.reasoningTokens", async () => {
setupStream({
inputTokens: 100,
outputTokens: 50,
details: { reasoningTokens: 15 },
})
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toBeDefined()
expect(usageChunk!.reasoningTokens).toBe(15)
})
})
})
})

View file

@ -84,7 +84,13 @@ describe("NativeOllamaHandler", () => {
expect(results).toHaveLength(3)
expect(results[0]).toEqual({ type: "text", text: "Hello" })
expect(results[1]).toEqual({ type: "text", text: " world" })
expect(results[2]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 2 })
expect(results[2]).toEqual({
type: "usage",
inputTokens: 10,
outputTokens: 2,
totalInputTokens: 10,
totalOutputTokens: 2,
})
})
it("should not include providerOptions by default (no num_ctx)", async () => {

View file

@ -353,4 +353,201 @@ describe("OpenAiNativeHandler - usage metrics", () => {
expect(callArgs.providerOptions.openai.promptCacheRetention).toBeUndefined()
})
})
describe("AI SDK v6 usage field paths", () => {
describe("cache tokens", () => {
it("should read cache tokens from v6 top-level cachedInputTokens", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 100,
outputTokens: 50,
cachedInputTokens: 30,
}),
providerMetadata: Promise.resolve({}),
content: Promise.resolve([]),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((c) => c.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0].cacheReadTokens).toBe(30)
})
it("should read cache tokens from v6 inputTokenDetails.cacheReadTokens", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 100,
outputTokens: 50,
inputTokenDetails: { cacheReadTokens: 25 },
}),
providerMetadata: Promise.resolve({}),
content: Promise.resolve([]),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((c) => c.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0].cacheReadTokens).toBe(25)
})
it("should prefer v6 top-level cachedInputTokens over legacy details", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 100,
outputTokens: 50,
cachedInputTokens: 30,
details: { cachedInputTokens: 20 },
}),
providerMetadata: Promise.resolve({}),
content: Promise.resolve([]),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((c) => c.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0].cacheReadTokens).toBe(30)
})
it("should read cacheWriteTokens from v6 inputTokenDetails.cacheWriteTokens", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 100,
outputTokens: 50,
inputTokenDetails: { cacheWriteTokens: 15 },
}),
providerMetadata: Promise.resolve({}),
content: Promise.resolve([]),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((c) => c.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0].cacheWriteTokens).toBe(15)
})
})
describe("reasoning tokens", () => {
it("should read reasoning tokens from v6 top-level reasoningTokens", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 100,
outputTokens: 50,
reasoningTokens: 40,
}),
providerMetadata: Promise.resolve({}),
content: Promise.resolve([]),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((c) => c.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0].reasoningTokens).toBe(40)
})
it("should read reasoning tokens from v6 outputTokenDetails.reasoningTokens", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 100,
outputTokens: 50,
outputTokenDetails: { reasoningTokens: 35 },
}),
providerMetadata: Promise.resolve({}),
content: Promise.resolve([]),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((c) => c.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0].reasoningTokens).toBe(35)
})
it("should prefer v6 top-level reasoningTokens over legacy details", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test" }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 100,
outputTokens: 50,
reasoningTokens: 40,
details: { reasoningTokens: 15 },
}),
providerMetadata: Promise.resolve({}),
content: Promise.resolve([]),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((c) => c.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0].reasoningTokens).toBe(40)
})
})
})
})

View file

@ -98,6 +98,8 @@ describe("OpenAiHandler with usage tracking fix", () => {
type: "usage",
inputTokens: 10,
outputTokens: 5,
totalInputTokens: 10,
totalOutputTokens: 5,
})
const lastChunk = chunks[chunks.length - 1]
@ -133,6 +135,8 @@ describe("OpenAiHandler with usage tracking fix", () => {
type: "usage",
inputTokens: 10,
outputTokens: 5,
totalInputTokens: 10,
totalOutputTokens: 5,
})
})
@ -229,5 +233,202 @@ describe("OpenAiHandler with usage tracking fix", () => {
}),
)
})
describe("AI SDK v6 usage field paths", () => {
describe("cache tokens", () => {
it("should read cache tokens from v6 top-level cachedInputTokens when providerMetadata is empty", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
mockStreamText.mockReturnValueOnce({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 100,
outputTokens: 50,
cachedInputTokens: 30,
}),
providerMetadata: Promise.resolve(undefined),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0].cacheReadTokens).toBe(30)
})
it("should read cache tokens from v6 inputTokenDetails.cacheReadTokens when providerMetadata is empty", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
mockStreamText.mockReturnValueOnce({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 100,
outputTokens: 50,
inputTokenDetails: { cacheReadTokens: 25 },
}),
providerMetadata: Promise.resolve(undefined),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0].cacheReadTokens).toBe(25)
})
it("should prefer providerMetadata.openai.cachedPromptTokens over v6 top-level", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
mockStreamText.mockReturnValueOnce({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 100,
outputTokens: 50,
cachedInputTokens: 30,
}),
providerMetadata: Promise.resolve({
openai: {
cachedPromptTokens: 80,
},
}),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0].cacheReadTokens).toBe(80)
})
it("should prefer v6 top-level cachedInputTokens over legacy details when providerMetadata is empty", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
mockStreamText.mockReturnValueOnce({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 100,
outputTokens: 50,
cachedInputTokens: 30,
details: { cachedInputTokens: 20 },
}),
providerMetadata: Promise.resolve(undefined),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0].cacheReadTokens).toBe(30)
})
})
describe("reasoning tokens", () => {
it("should read reasoning tokens from v6 top-level reasoningTokens when providerMetadata is empty", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
mockStreamText.mockReturnValueOnce({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 100,
outputTokens: 50,
reasoningTokens: 40,
}),
providerMetadata: Promise.resolve(undefined),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0].reasoningTokens).toBe(40)
})
it("should read reasoning tokens from v6 outputTokenDetails.reasoningTokens when providerMetadata is empty", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
mockStreamText.mockReturnValueOnce({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 100,
outputTokens: 50,
outputTokenDetails: { reasoningTokens: 35 },
}),
providerMetadata: Promise.resolve(undefined),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0].reasoningTokens).toBe(35)
})
it("should prefer providerMetadata.openai.reasoningTokens over v6 top-level", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
mockStreamText.mockReturnValueOnce({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 100,
outputTokens: 50,
reasoningTokens: 40,
}),
providerMetadata: Promise.resolve({
openai: {
reasoningTokens: 20,
},
}),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0].reasoningTokens).toBe(20)
})
})
})
})
})

View file

@ -178,6 +178,8 @@ describe("RequestyHandler", () => {
cacheReadTokens: 2,
reasoningTokens: undefined,
totalCost: expect.any(Number),
totalInputTokens: 10,
totalOutputTokens: 20,
})
})

View file

@ -105,7 +105,12 @@ function createMockStreamResult(options?: {
toolCallParts?: Array<{ type: string; id?: string; toolName?: string; delta?: string }>
inputTokens?: number
outputTokens?: number
providerMetadata?: Record<string, any>
providerMetadata?: Record<string, unknown>
usage?: {
cachedInputTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
details?: { cachedInputTokens?: number }
}
}) {
const {
textChunks = ["Test response"],
@ -114,6 +119,7 @@ function createMockStreamResult(options?: {
inputTokens = 10,
outputTokens = 5,
providerMetadata = undefined,
usage = undefined,
} = options ?? {}
const fullStream = (async function* () {
@ -130,7 +136,7 @@ function createMockStreamResult(options?: {
return {
fullStream,
usage: Promise.resolve({ inputTokens, outputTokens }),
usage: Promise.resolve({ inputTokens, outputTokens, ...usage }),
providerMetadata: Promise.resolve(providerMetadata),
}
}
@ -767,6 +773,65 @@ describe("RooHandler", () => {
expect(usageChunk).toBeDefined()
expect(usageChunk.cacheWriteTokens).toBe(20)
expect(usageChunk.cacheReadTokens).toBe(30)
expect(usageChunk.totalInputTokens).toBe(100)
})
it("should fall back to anthropic metadata when roo metadata is missing", async () => {
mockStreamText.mockReturnValue(
createMockStreamResult({
inputTokens: 120,
outputTokens: 40,
providerMetadata: {
anthropic: {
cacheCreationInputTokens: 25,
usage: {
cache_read_input_tokens: 35,
},
},
},
}),
)
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toBeDefined()
expect(usageChunk.inputTokens).toBe(120)
expect(usageChunk.cacheWriteTokens).toBe(25)
expect(usageChunk.cacheReadTokens).toBe(35)
expect(usageChunk.totalInputTokens).toBe(120)
})
it("should fall back to AI SDK usage cache fields when provider metadata is missing", async () => {
mockStreamText.mockReturnValue(
createMockStreamResult({
inputTokens: 140,
outputTokens: 30,
usage: {
cachedInputTokens: 22,
inputTokenDetails: {
cacheWriteTokens: 11,
},
},
}),
)
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toBeDefined()
expect(usageChunk.inputTokens).toBe(140)
expect(usageChunk.cacheWriteTokens).toBe(11)
expect(usageChunk.cacheReadTokens).toBe(22)
expect(usageChunk.totalInputTokens).toBe(140)
})
})

View file

@ -191,6 +191,8 @@ describe("VercelAiGatewayHandler", () => {
cacheWriteTokens: 2,
cacheReadTokens: 3,
totalCost: 0.005,
totalInputTokens: 10,
totalOutputTokens: 5,
})
})
@ -281,6 +283,8 @@ describe("VercelAiGatewayHandler", () => {
cacheWriteTokens: 2,
cacheReadTokens: 3,
totalCost: 0.005,
totalInputTokens: 10,
totalOutputTokens: 5,
})
})

View file

@ -221,6 +221,9 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple
cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined,
cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined,
totalCost,
// Anthropic: inputTokens is non-cached only; total = input + cache write + cache read
totalInputTokens: inputTokens + (cacheWriteTokens ?? 0) + (cacheReadTokens ?? 0),
totalOutputTokens: outputTokens,
}
}

View file

@ -209,6 +209,9 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined,
cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined,
totalCost,
// Anthropic: inputTokens is non-cached only; total = input + cache write + cache read
totalInputTokens: inputTokens + (cacheWriteTokens ?? 0) + (cacheReadTokens ?? 0),
totalOutputTokens: outputTokens,
}
}

View file

@ -90,6 +90,12 @@ export class AzureHandler extends BaseProvider implements SingleCompletionHandle
usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: {
cachedInputTokens?: number
reasoningTokens?: number
@ -102,19 +108,28 @@ export class AzureHandler extends BaseProvider implements SingleCompletionHandle
}
},
): ApiStreamUsageChunk {
// Extract cache metrics from Azure's providerMetadata if available
const cacheReadTokens = providerMetadata?.azure?.promptCacheHitTokens ?? usage.details?.cachedInputTokens
// Extract cache metrics from Azure's providerMetadata, then v6 fields, then legacy
const cacheReadTokens =
providerMetadata?.azure?.promptCacheHitTokens ??
usage.cachedInputTokens ??
usage.inputTokenDetails?.cacheReadTokens ??
usage.details?.cachedInputTokens
// Azure uses OpenAI-compatible caching which does not report cache write tokens separately;
// promptCacheMissTokens represents tokens NOT found in cache (processed from scratch), not tokens written to cache.
const cacheWriteTokens = undefined
const cacheWriteTokens = usage.inputTokenDetails?.cacheWriteTokens
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
return {
type: "usage",
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
reasoningTokens: usage.details?.reasoningTokens,
reasoningTokens:
usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens,
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -71,16 +71,29 @@ export class BasetenHandler extends BaseProvider implements SingleCompletionHand
protected processUsageMetrics(usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: {
cachedInputTokens?: number
reasoningTokens?: number
}
}): ApiStreamUsageChunk {
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
return {
type: "usage",
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
reasoningTokens: usage.details?.reasoningTokens,
inputTokens,
outputTokens,
cacheReadTokens:
usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens ?? usage.details?.cachedInputTokens,
reasoningTokens:
usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens,
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -351,7 +351,20 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
* Process usage metrics from the AI SDK response.
*/
private processUsageMetrics(
usage: { inputTokens?: number; outputTokens?: number },
usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: {
cachedInputTokens?: number
reasoningTokens?: number
}
},
info: ModelInfo,
providerMetadata?: Record<string, Record<string, unknown>>,
): ApiStreamUsageChunk {
@ -360,8 +373,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
// The AI SDK exposes reasoningTokens as a top-level field on usage, and also
// under outputTokenDetails.reasoningTokens — there is no .details property.
const reasoningTokens =
(usage as any).reasoningTokens ?? (usage as any).outputTokenDetails?.reasoningTokens ?? 0
const reasoningTokens = usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? 0
// Extract cache metrics primarily from usage (AI SDK standard locations),
// falling back to providerMetadata.bedrock.usage for provider-specific fields.
@ -369,12 +381,11 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
| { cacheReadInputTokens?: number; cacheWriteInputTokens?: number }
| undefined
const cacheReadTokens =
(usage as any).inputTokenDetails?.cacheReadTokens ??
(usage as any).cachedInputTokens ??
usage.cachedInputTokens ??
usage.inputTokenDetails?.cacheReadTokens ??
bedrockUsage?.cacheReadInputTokens ??
0
const cacheWriteTokens =
(usage as any).inputTokenDetails?.cacheWriteTokens ?? bedrockUsage?.cacheWriteInputTokens ?? 0
const cacheWriteTokens = usage.inputTokenDetails?.cacheWriteTokens ?? bedrockUsage?.cacheWriteInputTokens ?? 0
// For prompt routers, the AI SDK surfaces the invoked model ID in
// providerMetadata.bedrock.trace.promptRouter.invokedModelId.
@ -417,6 +428,9 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
reasoningTokens,
info: costInfo,
}),
// AI SDK normalizes inputTokens to total (OpenAI convention) for Bedrock
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -72,6 +72,12 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan
usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: {
cachedInputTokens?: number
reasoningTokens?: number
@ -84,17 +90,27 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan
}
},
): ApiStreamUsageChunk {
// Extract cache metrics from DeepSeek's providerMetadata
const cacheReadTokens = providerMetadata?.deepseek?.promptCacheHitTokens ?? usage.details?.cachedInputTokens
const cacheWriteTokens = providerMetadata?.deepseek?.promptCacheMissTokens
// Extract cache metrics from DeepSeek's providerMetadata, then v6 fields, then legacy
const cacheReadTokens =
providerMetadata?.deepseek?.promptCacheHitTokens ??
usage.cachedInputTokens ??
usage.inputTokenDetails?.cacheReadTokens ??
usage.details?.cachedInputTokens
const cacheWriteTokens =
providerMetadata?.deepseek?.promptCacheMissTokens ?? usage.inputTokenDetails?.cacheWriteTokens
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
return {
type: "usage",
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
reasoningTokens: usage.details?.reasoningTokens,
reasoningTokens:
usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens,
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -73,6 +73,12 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa
usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: {
cachedInputTokens?: number
reasoningTokens?: number
@ -85,17 +91,27 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa
}
},
): ApiStreamUsageChunk {
// Extract cache metrics from Fireworks' providerMetadata if available
const cacheReadTokens = providerMetadata?.fireworks?.promptCacheHitTokens ?? usage.details?.cachedInputTokens
const cacheWriteTokens = providerMetadata?.fireworks?.promptCacheMissTokens
// Extract cache metrics from Fireworks' providerMetadata, then v6 fields, then legacy
const cacheReadTokens =
providerMetadata?.fireworks?.promptCacheHitTokens ??
usage.cachedInputTokens ??
usage.inputTokenDetails?.cacheReadTokens ??
usage.details?.cachedInputTokens
const cacheWriteTokens =
providerMetadata?.fireworks?.promptCacheMissTokens ?? usage.inputTokenDetails?.cacheWriteTokens
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
return {
type: "usage",
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
reasoningTokens: usage.details?.reasoningTokens,
reasoningTokens:
usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens,
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -248,6 +248,12 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: {
cachedInputTokens?: number
reasoningTokens?: number
@ -258,8 +264,10 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
): ApiStreamUsageChunk {
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
const cacheReadTokens = usage.details?.cachedInputTokens
const reasoningTokens = usage.details?.reasoningTokens
const cacheReadTokens =
usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens ?? usage.details?.cachedInputTokens
const reasoningTokens =
usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens
return {
type: "usage",
@ -274,6 +282,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
cacheReadTokens,
reasoningTokens,
}),
// Gemini: inputTokens is already total
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -96,18 +96,30 @@ export class LiteLLMHandler extends OpenAICompatibleHandler implements SingleCom
protected override processUsageMetrics(usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: {
cachedInputTokens?: number
reasoningTokens?: number
}
raw?: Record<string, unknown>
}): ApiStreamUsageChunk {
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
return {
type: "usage",
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
cacheReadTokens: usage.details?.cachedInputTokens,
reasoningTokens: usage.details?.reasoningTokens,
inputTokens,
outputTokens,
cacheReadTokens:
usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens ?? usage.details?.cachedInputTokens,
reasoningTokens:
usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens,
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}
}

View file

@ -167,6 +167,9 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand
cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined,
cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined,
totalCost,
// MiniMax uses Anthropic SDK: inputTokens is non-cached only
totalInputTokens: inputTokens + (cacheWriteTokens ?? 0) + (cacheReadTokens ?? 0),
totalOutputTokens: outputTokens,
}
}

View file

@ -78,17 +78,29 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand
protected processUsageMetrics(usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: {
cachedInputTokens?: number
reasoningTokens?: number
}
}): ApiStreamUsageChunk {
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
return {
type: "usage",
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
cacheReadTokens: usage.details?.cachedInputTokens,
reasoningTokens: usage.details?.reasoningTokens,
inputTokens,
outputTokens,
cacheReadTokens:
usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens ?? usage.details?.cachedInputTokens,
reasoningTokens:
usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens,
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -46,6 +46,12 @@ export class MoonshotHandler extends OpenAICompatibleHandler {
protected override processUsageMetrics(usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: {
cachedInputTokens?: number
reasoningTokens?: number
@ -55,12 +61,20 @@ export class MoonshotHandler extends OpenAICompatibleHandler {
// Moonshot uses cached_tokens at the top level of raw usage data
const rawUsage = usage.raw as { cached_tokens?: number } | undefined
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
return {
type: "usage",
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
cacheWriteTokens: 0,
cacheReadTokens: rawUsage?.cached_tokens ?? usage.details?.cachedInputTokens,
inputTokens,
outputTokens,
cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens ?? 0,
cacheReadTokens:
rawUsage?.cached_tokens ??
usage.cachedInputTokens ??
usage.inputTokenDetails?.cacheReadTokens ??
usage.details?.cachedInputTokens,
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -126,10 +126,14 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
const usage = await result.usage
if (usage) {
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
yield {
type: "usage",
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
inputTokens,
outputTokens,
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -252,14 +252,27 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
if (usage) {
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
const details = (usage as any).details as
| { cachedInputTokens?: number; reasoningTokens?: number }
| undefined
const cacheReadTokens = details?.cachedInputTokens ?? 0
const typedUsage = usage as {
inputTokens?: number
outputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: { cachedInputTokens?: number; reasoningTokens?: number }
}
const cacheReadTokens =
typedUsage.cachedInputTokens ??
typedUsage.inputTokenDetails?.cacheReadTokens ??
typedUsage.details?.cachedInputTokens ??
0
// The OpenAI Responses API does not report cache write tokens separately;
// only cached (read) tokens are available via usage.details.cachedInputTokens.
const cacheWriteTokens = 0
const reasoningTokens = details?.reasoningTokens
const cacheWriteTokens = typedUsage.inputTokenDetails?.cacheWriteTokens ?? 0
const reasoningTokens =
typedUsage.reasoningTokens ??
typedUsage.outputTokenDetails?.reasoningTokens ??
typedUsage.details?.reasoningTokens
yield {
type: "usage",
@ -269,6 +282,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
cacheReadTokens: cacheReadTokens || undefined,
...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}),
totalCost: 0, // Subscription-based pricing
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}
} catch (usageError) {

View file

@ -97,6 +97,10 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si
protected processUsageMetrics(usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: {
cacheReadTokens?: number
cacheWriteTokens?: number
@ -111,15 +115,22 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si
}
raw?: Record<string, unknown>
}): ApiStreamUsageChunk {
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
return {
type: "usage",
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
// P1: AI SDK v6 standard (LanguageModelInputTokenDetails)
// P2: Legacy AI SDK standard (usage.details)
cacheReadTokens: usage.inputTokenDetails?.cacheReadTokens ?? usage.details?.cachedInputTokens,
inputTokens,
outputTokens,
// P1: AI SDK v6 top-level
// P2: AI SDK v6 structured (LanguageModelInputTokenDetails)
// P3: Legacy AI SDK standard (usage.details)
cacheReadTokens:
usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens ?? usage.details?.cachedInputTokens,
cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens,
reasoningTokens: usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens,
reasoningTokens:
usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens,
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -345,6 +345,12 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: {
cachedInputTokens?: number
reasoningTokens?: number
@ -356,11 +362,13 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
const cacheReadTokens = usage.details?.cachedInputTokens ?? 0
const cacheReadTokens =
usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens ?? usage.details?.cachedInputTokens ?? 0
// The OpenAI Responses API does not report cache write tokens separately;
// only cached (read) tokens are available via usage.details.cachedInputTokens.
const cacheWriteTokens = 0
const reasoningTokens = usage.details?.reasoningTokens
const cacheWriteTokens = usage.inputTokenDetails?.cacheWriteTokens ?? 0
const reasoningTokens =
usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens
const effectiveTier =
this.lastServiceTier || (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined
@ -382,6 +390,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
cacheReadTokens: cacheReadTokens || undefined,
...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}),
totalCost,
// OpenAI: inputTokens is already total
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -295,6 +295,12 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: {
cachedInputTokens?: number
reasoningTokens?: number
@ -309,16 +315,28 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
},
): ApiStreamUsageChunk {
// Extract cache and reasoning metrics from OpenAI's providerMetadata when available,
// falling back to usage.details for standard AI SDK fields.
const cacheReadTokens = providerMetadata?.openai?.cachedPromptTokens ?? usage.details?.cachedInputTokens
const reasoningTokens = providerMetadata?.openai?.reasoningTokens ?? usage.details?.reasoningTokens
// then v6 fields, then legacy usage.details.
const cacheReadTokens =
providerMetadata?.openai?.cachedPromptTokens ??
usage.cachedInputTokens ??
usage.inputTokenDetails?.cacheReadTokens ??
usage.details?.cachedInputTokens
const reasoningTokens =
providerMetadata?.openai?.reasoningTokens ??
usage.reasoningTokens ??
usage.outputTokenDetails?.reasoningTokens ??
usage.details?.reasoningTokens
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
return {
type: "usage",
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
inputTokens,
outputTokens,
cacheReadTokens,
reasoningTokens,
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -127,6 +127,9 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
...(cacheReadTokens > 0 ? { cacheReadTokens } : {}),
...(typeof reasoningTokens === "number" && reasoningTokens > 0 ? { reasoningTokens } : {}),
totalCost,
// OpenRouter uses OpenAI convention: inputTokens is already total
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -142,6 +142,12 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: {
cachedInputTokens?: number
reasoningTokens?: number
@ -152,8 +158,14 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
): ApiStreamUsageChunk {
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
const cacheWriteTokens = providerMetadata?.requesty?.usage?.cachingTokens ?? 0
const cacheReadTokens = providerMetadata?.requesty?.usage?.cachedTokens ?? usage.details?.cachedInputTokens ?? 0
const cacheWriteTokens =
providerMetadata?.requesty?.usage?.cachingTokens ?? usage.inputTokenDetails?.cacheWriteTokens ?? 0
const cacheReadTokens =
providerMetadata?.requesty?.usage?.cachedTokens ??
usage.cachedInputTokens ??
usage.inputTokenDetails?.cacheReadTokens ??
usage.details?.cachedInputTokens ??
0
const { totalCost } = modelInfo
? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
@ -165,8 +177,11 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
outputTokens,
cacheWriteTokens,
cacheReadTokens,
reasoningTokens: usage.details?.reasoningTokens,
reasoningTokens:
usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens,
totalCost,
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -29,6 +29,40 @@ import { t } from "../../i18n"
import type { RooMessage } from "../../core/task-persistence/rooMessage"
import { sanitizeMessagesForProvider } from "../transform/sanitize-messages"
type RooProviderMetadata = {
cost?: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
cached_tokens?: number
}
type AnthropicProviderMetadata = {
cacheCreationInputTokens?: number
cacheReadInputTokens?: number
usage?: {
cache_read_input_tokens?: number
}
}
type GatewayProviderMetadata = {
cost?: number
cache_creation_input_tokens?: number
cached_tokens?: number
}
type UsageWithCache = {
inputTokens?: number
outputTokens?: number
cachedInputTokens?: number
inputTokenDetails?: {
cacheReadTokens?: number
cacheWriteTokens?: number
}
details?: {
cachedInputTokens?: number
}
}
function getSessionToken(): string {
const token = CloudService.hasInstance() ? CloudService.instance.authService?.getSessionToken() : undefined
return token ?? "unauthenticated"
@ -95,6 +129,8 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler
messages: RooMessage[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
const firstNumber = (...values: Array<number | undefined>) => values.find((value) => typeof value === "number")
const model = this.getModel()
const { id: modelId, info } = model
@ -149,18 +185,40 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler
}
// Check provider metadata for usage details
const providerMetadata =
(await result.providerMetadata) ?? (await (result as any).experimental_providerMetadata)
const rooMeta = providerMetadata?.roo as Record<string, any> | undefined
const providerMetadata = (await result.providerMetadata) ?? undefined
const experimentalProviderMetadata = await (
result as { experimental_providerMetadata?: Promise<Record<string, unknown> | undefined> }
).experimental_providerMetadata
const metadataWithFallback = providerMetadata ?? experimentalProviderMetadata
const rooMeta = metadataWithFallback?.roo as RooProviderMetadata | undefined
const anthropicMeta = metadataWithFallback?.anthropic as AnthropicProviderMetadata | undefined
const gatewayMeta = metadataWithFallback?.gateway as GatewayProviderMetadata | undefined
// Process usage with protocol-aware normalization
const usage = await result.usage
const usage = (await result.usage) as UsageWithCache
const promptTokens = usage.inputTokens ?? 0
const completionTokens = usage.outputTokens ?? 0
// Extract cache tokens from provider metadata
const cacheCreation = (rooMeta?.cache_creation_input_tokens as number) ?? 0
const cacheRead = (rooMeta?.cache_read_input_tokens as number) ?? (rooMeta?.cached_tokens as number) ?? 0
// Extract cache tokens with priority chain (no double counting):
// Roo metadata -> Anthropic metadata -> Gateway metadata -> AI SDK usage -> legacy usage.details -> 0
const cacheCreation =
firstNumber(
rooMeta?.cache_creation_input_tokens,
anthropicMeta?.cacheCreationInputTokens,
gatewayMeta?.cache_creation_input_tokens,
usage.inputTokenDetails?.cacheWriteTokens,
) ?? 0
const cacheRead =
firstNumber(
rooMeta?.cache_read_input_tokens,
rooMeta?.cached_tokens,
anthropicMeta?.cacheReadInputTokens,
anthropicMeta?.usage?.cache_read_input_tokens,
gatewayMeta?.cached_tokens,
usage.cachedInputTokens,
usage.inputTokenDetails?.cacheReadTokens,
usage.details?.cachedInputTokens,
) ?? 0
// Protocol-aware token normalization:
// - OpenAI protocol expects TOTAL input tokens (cached + non-cached)
@ -171,7 +229,7 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler
// Cost: prefer server-side cost, fall back to client-side calculation
const isFreeModel = info.isFree === true
const serverCost = rooMeta?.cost as number | undefined
const serverCost = firstNumber(rooMeta?.cost, gatewayMeta?.cost)
const { totalCost: calculatedCost } = calculateApiCostOpenAI(
info,
promptTokens,
@ -188,6 +246,9 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler
cacheWriteTokens: cacheCreation,
cacheReadTokens: cacheRead,
totalCost,
// Roo: promptTokens is always the server-reported total regardless of protocol normalization
totalInputTokens: promptTokens,
totalOutputTokens: completionTokens,
}
yield* yieldResponseMessage(result)

View file

@ -74,6 +74,12 @@ export class SambaNovaHandler extends BaseProvider implements SingleCompletionHa
usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: {
cachedInputTokens?: number
reasoningTokens?: number
@ -86,17 +92,27 @@ export class SambaNovaHandler extends BaseProvider implements SingleCompletionHa
}
},
): ApiStreamUsageChunk {
// Extract cache metrics from SambaNova's providerMetadata if available
const cacheReadTokens = providerMetadata?.sambanova?.promptCacheHitTokens ?? usage.details?.cachedInputTokens
const cacheWriteTokens = providerMetadata?.sambanova?.promptCacheMissTokens
// Extract cache metrics from SambaNova's providerMetadata, then v6 fields, then legacy
const cacheReadTokens =
providerMetadata?.sambanova?.promptCacheHitTokens ??
usage.cachedInputTokens ??
usage.inputTokenDetails?.cacheReadTokens ??
usage.details?.cachedInputTokens
const cacheWriteTokens =
providerMetadata?.sambanova?.promptCacheMissTokens ?? usage.inputTokenDetails?.cacheWriteTokens
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
return {
type: "usage",
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
reasoningTokens: usage.details?.reasoningTokens,
reasoningTokens:
usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens,
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -87,6 +87,12 @@ export class VercelAiGatewayHandler extends BaseProvider implements SingleComple
usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: {
cachedInputTokens?: number
reasoningTokens?: number
@ -96,17 +102,29 @@ export class VercelAiGatewayHandler extends BaseProvider implements SingleComple
): ApiStreamUsageChunk {
const gatewayMeta = providerMetadata?.gateway as Record<string, unknown> | undefined
const cacheWriteTokens = (gatewayMeta?.cache_creation_input_tokens as number) ?? undefined
const cacheReadTokens = usage.details?.cachedInputTokens ?? (gatewayMeta?.cached_tokens as number) ?? undefined
const cacheWriteTokens =
(gatewayMeta?.cache_creation_input_tokens as number) ??
usage.inputTokenDetails?.cacheWriteTokens ??
undefined
const cacheReadTokens =
usage.cachedInputTokens ??
usage.inputTokenDetails?.cacheReadTokens ??
usage.details?.cachedInputTokens ??
(gatewayMeta?.cached_tokens as number) ??
undefined
const totalCost = (gatewayMeta?.cost as number) ?? 0
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
return {
type: "usage",
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
inputTokens,
outputTokens,
cacheWriteTokens,
cacheReadTokens,
totalCost,
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -229,6 +229,12 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: {
cachedInputTokens?: number
reasoningTokens?: number
@ -239,8 +245,10 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
): ApiStreamUsageChunk {
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
const cacheReadTokens = usage.details?.cachedInputTokens
const reasoningTokens = usage.details?.reasoningTokens
const cacheReadTokens =
usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens ?? usage.details?.cachedInputTokens
const reasoningTokens =
usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens
return {
type: "usage",
@ -255,6 +263,9 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
cacheReadTokens,
reasoningTokens,
}),
// Vertex: inputTokens is already total
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -475,6 +475,8 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan
type: "usage",
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
totalInputTokens,
totalOutputTokens,
}
} catch (error: unknown) {
this.ensureCleanState()

View file

@ -82,6 +82,12 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler
usage: {
inputTokens?: number
outputTokens?: number
totalInputTokens?: number
totalOutputTokens?: number
cachedInputTokens?: number
reasoningTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
details?: {
cachedInputTokens?: number
reasoningTokens?: number
@ -93,17 +99,25 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler
}
},
): ApiStreamUsageChunk {
// Extract cache metrics from xAI's providerMetadata if available
// xAI supports prompt caching through prompt_tokens_details.cached_tokens
const cacheReadTokens = providerMetadata?.xai?.cachedPromptTokens ?? usage.details?.cachedInputTokens
// Extract cache metrics from xAI's providerMetadata, then v6 fields, then legacy
const cacheReadTokens =
providerMetadata?.xai?.cachedPromptTokens ??
usage.cachedInputTokens ??
usage.inputTokenDetails?.cacheReadTokens ??
usage.details?.cachedInputTokens
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
return {
type: "usage",
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens: undefined, // xAI doesn't report cache write tokens separately
reasoningTokens: usage.details?.reasoningTokens,
cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens, // xAI doesn't typically report cache write tokens
reasoningTokens:
usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens ?? usage.details?.reasoningTokens,
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}

View file

@ -66,6 +66,10 @@ export interface ApiStreamUsageChunk {
cacheReadTokens?: number
reasoningTokens?: number
totalCost?: number
/** Total input tokens including cache read/write tokens. Each provider computes this directly. */
totalInputTokens?: number
/** Total output tokens. Each provider computes this directly. */
totalOutputTokens?: number
}
export interface ApiStreamGroundingChunk {

View file

@ -89,7 +89,6 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor"
// utils
import { calculateApiCostAnthropic, calculateApiCostOpenAI } from "../../shared/cost"
import { getWorkspacePath } from "../../utils/path"
import { sanitizeToolUseId } from "../../utils/tool-id"
import { getTaskDirectoryPath } from "../../utils/storage"
@ -2894,6 +2893,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
let inputTokens = 0
let outputTokens = 0
let totalCost: number | undefined
let totalInputTokensAccum = 0
let totalOutputTokensAccum = 0
// We can't use `api_req_finished` anymore since it's a unique case
// where it could come after a streaming message (i.e. in the middle
@ -2909,38 +2910,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const existingData = JSON.parse(this.clineMessages[lastApiReqIndex].text || "{}")
// Calculate total tokens and cost using provider-aware function
const modelId = getModelId(this.apiConfiguration)
const apiProvider = this.apiConfiguration.apiProvider
const apiProtocol = getApiProtocol(
apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined,
modelId,
)
const costResult =
apiProtocol === "anthropic"
? calculateApiCostAnthropic(
streamModelInfo,
inputTokens,
outputTokens,
cacheWriteTokens,
cacheReadTokens,
)
: calculateApiCostOpenAI(
streamModelInfo,
inputTokens,
outputTokens,
cacheWriteTokens,
cacheReadTokens,
)
// Use provider-computed totals when available, falling back to raw token counts
this.clineMessages[lastApiReqIndex].text = JSON.stringify({
...existingData,
tokensIn: costResult.totalInputTokens,
tokensOut: costResult.totalOutputTokens,
tokensIn: totalInputTokensAccum || inputTokens,
tokensOut: totalOutputTokensAccum || outputTokens,
cacheWrites: cacheWriteTokens,
cacheReads: cacheReadTokens,
cost: totalCost ?? costResult.totalCost,
cost: totalCost ?? existingData.cost,
cancelReason,
streamingFailedMessage,
} satisfies ClineApiReqInfo)
@ -3069,7 +3046,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
outputTokens += chunk.outputTokens
cacheWriteTokens += chunk.cacheWriteTokens ?? 0
cacheReadTokens += chunk.cacheReadTokens ?? 0
totalCost = chunk.totalCost
totalCost = chunk.totalCost ?? totalCost
totalInputTokensAccum += chunk.totalInputTokens ?? 0
totalOutputTokensAccum += chunk.totalOutputTokens ?? 0
break
case "grounding":
// Handle grounding sources separately from regular content
@ -3202,6 +3181,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
cacheWrite: cacheWriteTokens,
cacheRead: cacheReadTokens,
total: totalCost,
totalIn: totalInputTokensAccum,
totalOut: totalOutputTokensAccum,
}
const drainStreamInBackgroundToFindAllUsage = async (apiReqIndex: number) => {
@ -3215,6 +3196,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
let bgCacheWriteTokens = currentTokens.cacheWrite
let bgCacheReadTokens = currentTokens.cacheRead
let bgTotalCost = currentTokens.total
let bgTotalInputTokens = currentTokens.totalIn
let bgTotalOutputTokens = currentTokens.totalOut
// Helper function to capture telemetry and update messages
const captureUsageData = async (
@ -3224,6 +3207,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
cacheWrite: number
cacheRead: number
total?: number
totalIn: number
totalOut: number
},
messageIndex: number = apiReqIndex,
) => {
@ -3238,7 +3223,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
outputTokens = tokens.output
cacheWriteTokens = tokens.cacheWrite
cacheReadTokens = tokens.cacheRead
totalCost = tokens.total
totalCost = tokens.total ?? totalCost
totalInputTokensAccum = tokens.totalIn
totalOutputTokensAccum = tokens.totalOut
// Update the API request message with the latest usage data
updateApiReqMsg()
@ -3250,38 +3237,20 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
await this.updateClineMessage(apiReqMessage)
}
// Capture telemetry with provider-aware cost calculation
const modelId = getModelId(this.apiConfiguration)
const apiProvider = this.apiConfiguration.apiProvider
const apiProtocol = getApiProtocol(
apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined,
modelId,
)
// Use the appropriate cost function based on the API protocol
const costResult =
apiProtocol === "anthropic"
? calculateApiCostAnthropic(
streamModelInfo,
tokens.input,
tokens.output,
tokens.cacheWrite,
tokens.cacheRead,
)
: calculateApiCostOpenAI(
streamModelInfo,
tokens.input,
tokens.output,
tokens.cacheWrite,
tokens.cacheRead,
)
const messageData = JSON.parse(
this.clineMessages[messageIndex]?.text || "{}",
) as ClineApiReqInfo
const telemetryCost =
tokens.total ??
(typeof messageData.cost === "number" ? messageData.cost : undefined)
// Use provider-computed totals for telemetry, falling back to raw counts
TelemetryService.instance.captureLlmCompletion(this.taskId, {
inputTokens: costResult.totalInputTokens,
outputTokens: costResult.totalOutputTokens,
inputTokens: tokens.totalIn || tokens.input,
outputTokens: tokens.totalOut || tokens.output,
cacheWriteTokens: tokens.cacheWrite,
cacheReadTokens: tokens.cacheRead,
cost: tokens.total ?? costResult.totalCost,
cost: telemetryCost,
})
}
}
@ -3315,7 +3284,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
bgOutputTokens += chunk.outputTokens
bgCacheWriteTokens += chunk.cacheWriteTokens ?? 0
bgCacheReadTokens += chunk.cacheReadTokens ?? 0
bgTotalCost = chunk.totalCost
bgTotalCost = chunk.totalCost ?? bgTotalCost
bgTotalInputTokens += chunk.totalInputTokens ?? 0
bgTotalOutputTokens += chunk.totalOutputTokens ?? 0
}
}
@ -3334,6 +3305,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
cacheWrite: bgCacheWriteTokens,
cacheRead: bgCacheReadTokens,
total: bgTotalCost,
totalIn: bgTotalInputTokens,
totalOut: bgTotalOutputTokens,
},
lastApiReqIndex,
)
@ -3358,6 +3331,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
cacheWrite: bgCacheWriteTokens,
cacheRead: bgCacheReadTokens,
total: bgTotalCost,
totalIn: bgTotalInputTokens,
totalOut: bgTotalOutputTokens,
},
lastApiReqIndex,
)