Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 54de1ab

Browse files
committed
fix: restore missing functionality in OpenRouter AI SDK refactor
- Add getReasoningDetails() method to preserve reasoning context for multi-turn conversations - Restore telemetry reporting with TelemetryService.captureException() in error handlers - Restore detailed usage metrics (totalCost, cacheReadTokens, reasoningTokens, cacheWriteTokens) Addresses review comments on PR #10778
1 parent d062770 commit 54de1ab

2 files changed

Lines changed: 439 additions & 10 deletions

File tree

src/api/providers/__tests__/openrouter.spec.ts

Lines changed: 302 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ describe("OpenRouterHandler", () => {
219219
})
220220

221221
describe("createMessage", () => {
222-
it("generates correct stream chunks", async () => {
222+
it("generates correct stream chunks with basic usage and totalCost", async () => {
223223
const handler = new OpenRouterHandler(mockOptions)
224224

225225
// Create mock async iterator for fullStream
@@ -235,6 +235,7 @@ describe("OpenRouterHandler", () => {
235235
fullStream: mockFullStream,
236236
usage: mockUsage,
237237
totalUsage: mockTotalUsage,
238+
providerMetadata: Promise.resolve(undefined),
238239
})
239240

240241
const systemPrompt = "test system prompt"
@@ -250,7 +251,16 @@ describe("OpenRouterHandler", () => {
250251
// Verify stream chunks - should have text and usage chunks
251252
expect(chunks).toHaveLength(2)
252253
expect(chunks[0]).toEqual({ type: "text", text: "test response" })
253-
expect(chunks[1]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 20 })
254+
// Usage chunk should include totalCost calculated from model pricing
255+
// Model: anthropic/claude-sonnet-4 with inputPrice: 3, outputPrice: 15 (per million)
256+
// Cost = (10 * 3 / 1_000_000) + (20 * 15 / 1_000_000) = 0.00003 + 0.0003 = 0.00033
257+
expect(chunks[1]).toMatchObject({
258+
type: "usage",
259+
inputTokens: 10,
260+
outputTokens: 20,
261+
totalCost: expect.any(Number),
262+
})
263+
expect((chunks[1] as any).totalCost).toBeCloseTo(0.00033, 6)
254264

255265
// Verify streamText was called with correct parameters
256266
expect(mockStreamText).toHaveBeenCalledWith(
@@ -263,6 +273,155 @@ describe("OpenRouterHandler", () => {
263273
)
264274
})
265275

276+
it("includes cache read tokens in usage when provider metadata contains them", async () => {
277+
const handler = new OpenRouterHandler(mockOptions)
278+
279+
const mockFullStream = (async function* () {
280+
yield { type: "text-delta", text: "test", id: "1" }
281+
})()
282+
283+
mockStreamText.mockReturnValue({
284+
fullStream: mockFullStream,
285+
usage: Promise.resolve({ inputTokens: 100, outputTokens: 50, totalTokens: 150 }),
286+
totalUsage: Promise.resolve({ inputTokens: 100, outputTokens: 50, totalTokens: 150 }),
287+
providerMetadata: Promise.resolve({
288+
openrouter: {
289+
cachedInputTokens: 30,
290+
},
291+
}),
292+
})
293+
294+
const generator = handler.createMessage("test", [{ role: "user", content: "test" }])
295+
const chunks = []
296+
297+
for await (const chunk of generator) {
298+
chunks.push(chunk)
299+
}
300+
301+
const usageChunk = chunks.find((c) => c.type === "usage")
302+
expect(usageChunk).toBeDefined()
303+
expect(usageChunk).toMatchObject({
304+
type: "usage",
305+
inputTokens: 100,
306+
outputTokens: 50,
307+
cacheReadTokens: 30,
308+
totalCost: expect.any(Number),
309+
})
310+
})
311+
312+
it("includes reasoning tokens in usage when provider metadata contains them", async () => {
313+
const handler = new OpenRouterHandler(mockOptions)
314+
315+
const mockFullStream = (async function* () {
316+
yield { type: "text-delta", text: "test", id: "1" }
317+
})()
318+
319+
mockStreamText.mockReturnValue({
320+
fullStream: mockFullStream,
321+
usage: Promise.resolve({ inputTokens: 100, outputTokens: 150, totalTokens: 250 }),
322+
totalUsage: Promise.resolve({ inputTokens: 100, outputTokens: 150, totalTokens: 250 }),
323+
providerMetadata: Promise.resolve({
324+
openrouter: {
325+
reasoningOutputTokens: 50,
326+
},
327+
}),
328+
})
329+
330+
const generator = handler.createMessage("test", [{ role: "user", content: "test" }])
331+
const chunks = []
332+
333+
for await (const chunk of generator) {
334+
chunks.push(chunk)
335+
}
336+
337+
const usageChunk = chunks.find((c) => c.type === "usage")
338+
expect(usageChunk).toBeDefined()
339+
expect(usageChunk).toMatchObject({
340+
type: "usage",
341+
inputTokens: 100,
342+
outputTokens: 150,
343+
reasoningTokens: 50,
344+
totalCost: expect.any(Number),
345+
})
346+
})
347+
348+
it("includes all detailed usage metrics when provider metadata contains them", async () => {
349+
const handler = new OpenRouterHandler(mockOptions)
350+
351+
const mockFullStream = (async function* () {
352+
yield { type: "text-delta", text: "test", id: "1" }
353+
})()
354+
355+
mockStreamText.mockReturnValue({
356+
fullStream: mockFullStream,
357+
usage: Promise.resolve({ inputTokens: 200, outputTokens: 100, totalTokens: 300 }),
358+
totalUsage: Promise.resolve({ inputTokens: 200, outputTokens: 100, totalTokens: 300 }),
359+
providerMetadata: Promise.resolve({
360+
openrouter: {
361+
cachedInputTokens: 50,
362+
cacheCreationInputTokens: 20,
363+
reasoningOutputTokens: 30,
364+
},
365+
}),
366+
})
367+
368+
const generator = handler.createMessage("test", [{ role: "user", content: "test" }])
369+
const chunks = []
370+
371+
for await (const chunk of generator) {
372+
chunks.push(chunk)
373+
}
374+
375+
const usageChunk = chunks.find((c) => c.type === "usage")
376+
expect(usageChunk).toBeDefined()
377+
expect(usageChunk).toMatchObject({
378+
type: "usage",
379+
inputTokens: 200,
380+
outputTokens: 100,
381+
cacheReadTokens: 50,
382+
cacheWriteTokens: 20,
383+
reasoningTokens: 30,
384+
totalCost: expect.any(Number),
385+
})
386+
})
387+
388+
it("handles experimental_providerMetadata fallback", async () => {
389+
const handler = new OpenRouterHandler(mockOptions)
390+
391+
const mockFullStream = (async function* () {
392+
yield { type: "text-delta", text: "test", id: "1" }
393+
})()
394+
395+
mockStreamText.mockReturnValue({
396+
fullStream: mockFullStream,
397+
usage: Promise.resolve({ inputTokens: 100, outputTokens: 50, totalTokens: 150 }),
398+
totalUsage: Promise.resolve({ inputTokens: 100, outputTokens: 50, totalTokens: 150 }),
399+
providerMetadata: Promise.resolve(undefined),
400+
experimental_providerMetadata: Promise.resolve({
401+
openrouter: {
402+
cachedInputTokens: 25,
403+
},
404+
}),
405+
})
406+
407+
const generator = handler.createMessage("test", [{ role: "user", content: "test" }])
408+
const chunks = []
409+
410+
for await (const chunk of generator) {
411+
chunks.push(chunk)
412+
}
413+
414+
const usageChunk = chunks.find((c) => c.type === "usage")
415+
expect(usageChunk).toBeDefined()
416+
expect(usageChunk).toMatchObject({
417+
type: "usage",
418+
inputTokens: 100,
419+
outputTokens: 50,
420+
cacheReadTokens: 25,
421+
totalCost: expect.any(Number),
422+
})
423+
})
424+
266425
it("handles reasoning delta chunks", async () => {
267426
const handler = new OpenRouterHandler(mockOptions)
268427

@@ -288,6 +447,36 @@ describe("OpenRouterHandler", () => {
288447
expect(chunks[1]).toEqual({ type: "text", text: "result" })
289448
})
290449

450+
it("accumulates reasoning details for getReasoningDetails()", async () => {
451+
const handler = new OpenRouterHandler(mockOptions)
452+
453+
const mockFullStream = (async function* () {
454+
yield { type: "reasoning-delta", text: "step 1...", id: "1" }
455+
yield { type: "reasoning-delta", text: "step 2...", id: "2" }
456+
yield { type: "text-delta", text: "result", id: "3" }
457+
})()
458+
459+
mockStreamText.mockReturnValue({
460+
fullStream: mockFullStream,
461+
usage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }),
462+
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }),
463+
})
464+
465+
const generator = handler.createMessage("test", [{ role: "user", content: "test" }])
466+
467+
for await (const _ of generator) {
468+
// consume all chunks
469+
}
470+
471+
// After streaming, getReasoningDetails should return accumulated reasoning
472+
const reasoningDetails = handler.getReasoningDetails()
473+
expect(reasoningDetails).toBeDefined()
474+
expect(reasoningDetails).toHaveLength(1)
475+
expect(reasoningDetails![0].type).toBe("reasoning.text")
476+
expect(reasoningDetails![0].text).toBe("step 1...step 2...")
477+
expect(reasoningDetails![0].index).toBe(0)
478+
})
479+
291480
it("handles tool call streaming", async () => {
292481
const handler = new OpenRouterHandler(mockOptions)
293482

@@ -369,6 +558,16 @@ describe("OpenRouterHandler", () => {
369558
error: "OpenRouterError",
370559
message: "OpenRouter API Error: API Error",
371560
})
561+
562+
// Verify telemetry was called
563+
expect(mockCaptureException).toHaveBeenCalledTimes(1)
564+
expect(mockCaptureException).toHaveBeenCalledWith(
565+
expect.objectContaining({
566+
message: "API Error",
567+
provider: "OpenRouter",
568+
operation: "createMessage",
569+
}),
570+
)
372571
})
373572

374573
it("handles stream errors", async () => {
@@ -469,6 +668,16 @@ describe("OpenRouterHandler", () => {
469668
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
470669
"OpenRouter completion error: API Error",
471670
)
671+
672+
// Verify telemetry was called
673+
expect(mockCaptureException).toHaveBeenCalledTimes(1)
674+
expect(mockCaptureException).toHaveBeenCalledWith(
675+
expect.objectContaining({
676+
message: "API Error",
677+
provider: "OpenRouter",
678+
operation: "completePrompt",
679+
}),
680+
)
472681
})
473682

474683
it("handles rate limit errors", async () => {
@@ -479,6 +688,16 @@ describe("OpenRouterHandler", () => {
479688
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
480689
"OpenRouter completion error: Rate limit exceeded",
481690
)
691+
692+
// Verify telemetry was called
693+
expect(mockCaptureException).toHaveBeenCalledTimes(1)
694+
expect(mockCaptureException).toHaveBeenCalledWith(
695+
expect.objectContaining({
696+
message: "Rate limit exceeded",
697+
provider: "OpenRouter",
698+
operation: "completePrompt",
699+
}),
700+
)
482701
})
483702
})
484703

@@ -539,4 +758,85 @@ describe("OpenRouterHandler", () => {
539758
})
540759
})
541760
})
761+
762+
describe("getReasoningDetails", () => {
763+
it("returns undefined when no reasoning was captured", async () => {
764+
const handler = new OpenRouterHandler(mockOptions)
765+
766+
// Stream with no reasoning
767+
const mockFullStream = (async function* () {
768+
yield { type: "text-delta", text: "just text", id: "1" }
769+
})()
770+
771+
mockStreamText.mockReturnValue({
772+
fullStream: mockFullStream,
773+
usage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }),
774+
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }),
775+
})
776+
777+
const generator = handler.createMessage("test", [{ role: "user", content: "test" }])
778+
779+
for await (const _ of generator) {
780+
// consume all chunks
781+
}
782+
783+
// No reasoning was captured, should return undefined
784+
const reasoningDetails = handler.getReasoningDetails()
785+
expect(reasoningDetails).toBeUndefined()
786+
})
787+
788+
it("resets reasoning details between requests", async () => {
789+
const handler = new OpenRouterHandler(mockOptions)
790+
791+
// First request with reasoning
792+
const mockFullStream1 = (async function* () {
793+
yield { type: "reasoning-delta", text: "first request reasoning", id: "1" }
794+
yield { type: "text-delta", text: "result 1", id: "2" }
795+
})()
796+
797+
mockStreamText.mockReturnValue({
798+
fullStream: mockFullStream1,
799+
usage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }),
800+
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }),
801+
})
802+
803+
const generator1 = handler.createMessage("test", [{ role: "user", content: "test" }])
804+
for await (const _ of generator1) {
805+
// consume
806+
}
807+
808+
// Verify first request captured reasoning
809+
let reasoningDetails = handler.getReasoningDetails()
810+
expect(reasoningDetails).toBeDefined()
811+
expect(reasoningDetails![0].text).toBe("first request reasoning")
812+
813+
// Second request without reasoning
814+
const mockFullStream2 = (async function* () {
815+
yield { type: "text-delta", text: "result 2", id: "1" }
816+
})()
817+
818+
mockStreamText.mockReturnValue({
819+
fullStream: mockFullStream2,
820+
usage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }),
821+
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }),
822+
})
823+
824+
const generator2 = handler.createMessage("test", [{ role: "user", content: "test" }])
825+
for await (const _ of generator2) {
826+
// consume
827+
}
828+
829+
// Reasoning details should be reset (undefined since second request had no reasoning)
830+
reasoningDetails = handler.getReasoningDetails()
831+
expect(reasoningDetails).toBeUndefined()
832+
})
833+
834+
it("returns undefined before any streaming occurs", () => {
835+
const handler = new OpenRouterHandler(mockOptions)
836+
837+
// getReasoningDetails before any createMessage call
838+
const reasoningDetails = handler.getReasoningDetails()
839+
expect(reasoningDetails).toBeUndefined()
840+
})
841+
})
542842
})

0 commit comments

Comments
 (0)