@@ -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