@@ -81,6 +81,20 @@ vitest.mock("../fetchers/modelCache", () => ({
8181 description : "Claude 4.5 Sonnet" ,
8282 thinking : false ,
8383 } ,
84+ // Not in OPEN_ROUTER_PROMPT_CACHING_MODELS: stands in for a Claude
85+ // model released after the static allowlist was last edited.
86+ "anthropic/claude-opus-4.8" : {
87+ maxTokens : 128000 ,
88+ contextWindow : 1000000 ,
89+ supportsImages : true ,
90+ supportsPromptCache : true ,
91+ inputPrice : 5 ,
92+ outputPrice : 25 ,
93+ cacheWritesPrice : 6.25 ,
94+ cacheReadsPrice : 0.5 ,
95+ description : "Claude 4.8 Opus" ,
96+ thinking : false ,
97+ } ,
8498 "anthropic/claude-3.7-sonnet:thinking" : {
8599 maxTokens : 128000 ,
86100 contextWindow : 200000 ,
@@ -485,6 +499,201 @@ describe("OpenRouterHandler", () => {
485499 )
486500 } )
487501
502+ it ( "adds cache control for a Claude model missing from the static allowlist" , async ( ) => {
503+ const handler = new OpenRouterHandler (
504+ makeApiHandlerOptions ( {
505+ ...mockOptions ,
506+ openRouterModelId : "anthropic/claude-opus-4.8" ,
507+ } ) ,
508+ )
509+
510+ const mockStream = asyncStreamFrom ( [ { id : "test-id" , choices : [ { delta : { content : "test response" } } ] } ] )
511+ const mockCreate = vitest . fn ( ) . mockResolvedValue ( mockStream )
512+ const chatStub = { completions : { create : mockCreate } }
513+ const openAiPrototype = OpenAI as unknown as { prototype : { chat ?: unknown } }
514+ const originalChat = openAiPrototype . prototype . chat
515+ openAiPrototype . prototype . chat = chatStub
516+
517+ try {
518+ await handler . createMessage ( "test system" , [ { role : "user" , content : "test message" } ] ) . next ( )
519+
520+ const [ requestParams ] = mockCreate . mock . calls [ 0 ] as [ Record < string , unknown > ]
521+ expect ( requestParams ) . toMatchObject ( {
522+ messages : expect . arrayContaining ( [
523+ expect . objectContaining ( {
524+ role : "system" ,
525+ content : expect . arrayContaining ( [
526+ expect . objectContaining ( { cache_control : { type : "ephemeral" } } ) ,
527+ ] ) ,
528+ } ) ,
529+ ] ) ,
530+ } )
531+ } finally {
532+ openAiPrototype . prototype . chat = originalChat
533+ }
534+ } )
535+
536+ it ( "keeps Gemini cache breakpoints allowlist-only" , async ( ) => {
537+ // A Gemini model absent from the allowlist must not gain Anthropic-style
538+ // breakpoints just because its catalog record advertises cache pricing.
539+ const handler = new OpenRouterHandler (
540+ makeApiHandlerOptions ( {
541+ ...mockOptions ,
542+ openRouterModelId : "google/gemini-9-pro" ,
543+ } ) ,
544+ )
545+
546+ const mockStream = asyncStreamFrom ( [ { id : "test-id" , choices : [ { delta : { content : "test response" } } ] } ] )
547+ const mockCreate = vitest . fn ( ) . mockResolvedValue ( mockStream )
548+ const chatStub = { completions : { create : mockCreate } }
549+ const openAiPrototype = OpenAI as unknown as { prototype : { chat ?: unknown } }
550+ const originalChat = openAiPrototype . prototype . chat
551+ openAiPrototype . prototype . chat = chatStub
552+
553+ try {
554+ await handler . createMessage ( "test system" , [ { role : "user" , content : "test message" } ] ) . next ( )
555+
556+ const [ requestParams ] = mockCreate . mock . calls [ 0 ] as [ Record < string , unknown > ]
557+ expect ( requestParams ) . toMatchObject ( {
558+ messages : [ { role : "system" , content : "test system" } , expect . anything ( ) ] ,
559+ } )
560+ } finally {
561+ openAiPrototype . prototype . chat = originalChat
562+ }
563+ } )
564+
565+ it ( "sends the task ID as the OpenRouter cache session affinity key" , async ( ) => {
566+ const handler = new OpenRouterHandler ( mockOptions )
567+
568+ const mockStream = asyncStreamFrom ( [ { id : "test-id" , choices : [ { delta : { content : "test response" } } ] } ] )
569+ const mockCreate = vitest . fn ( ) . mockResolvedValue ( mockStream )
570+ const chatStub = { completions : { create : mockCreate } }
571+ const openAiPrototype = OpenAI as unknown as { prototype : { chat ?: unknown } }
572+ const originalChat = openAiPrototype . prototype . chat
573+ openAiPrototype . prototype . chat = chatStub
574+
575+ try {
576+ await handler
577+ . createMessage ( "test system" , [ { role : "user" , content : "test message" } ] , { taskId : "task-123" } )
578+ . next ( )
579+
580+ const [ requestParams ] = mockCreate . mock . calls [ 0 ] as [ Record < string , unknown > ]
581+ expect ( requestParams ) . toMatchObject ( { session_id : "task-123" } )
582+ } finally {
583+ openAiPrototype . prototype . chat = originalChat
584+ }
585+ } )
586+
587+ it ( "omits session_id when no task ID is available" , async ( ) => {
588+ const handler = new OpenRouterHandler ( mockOptions )
589+
590+ const mockStream = asyncStreamFrom ( [ { id : "test-id" , choices : [ { delta : { content : "test response" } } ] } ] )
591+ const mockCreate = vitest . fn ( ) . mockResolvedValue ( mockStream )
592+ const chatStub = { completions : { create : mockCreate } }
593+ const openAiPrototype = OpenAI as unknown as { prototype : { chat ?: unknown } }
594+ const originalChat = openAiPrototype . prototype . chat
595+ openAiPrototype . prototype . chat = chatStub
596+
597+ try {
598+ await handler . createMessage ( "test system" , [ { role : "user" , content : "test message" } ] ) . next ( )
599+
600+ const [ requestParams ] = mockCreate . mock . calls [ 0 ] as [ Record < string , unknown > ]
601+ expect ( requestParams ) . not . toHaveProperty ( "session_id" )
602+ } finally {
603+ openAiPrototype . prototype . chat = originalChat
604+ }
605+ } )
606+
607+ it ( "truncates an over-long task ID to OpenRouter's 256-character session_id limit" , async ( ) => {
608+ const handler = new OpenRouterHandler ( mockOptions )
609+
610+ const mockStream = asyncStreamFrom ( [ { id : "test-id" , choices : [ { delta : { content : "test response" } } ] } ] )
611+ const mockCreate = vitest . fn ( ) . mockResolvedValue ( mockStream )
612+ const chatStub = { completions : { create : mockCreate } }
613+ const openAiPrototype = OpenAI as unknown as { prototype : { chat ?: unknown } }
614+ const originalChat = openAiPrototype . prototype . chat
615+ openAiPrototype . prototype . chat = chatStub
616+
617+ try {
618+ await handler
619+ . createMessage ( "test system" , [ { role : "user" , content : "test message" } ] , {
620+ taskId : "a" . repeat ( 300 ) ,
621+ } )
622+ . next ( )
623+
624+ const [ requestParams ] = mockCreate . mock . calls [ 0 ] as [ Record < string , unknown > ]
625+ expect ( requestParams . session_id ) . toBe ( "a" . repeat ( 256 ) )
626+ } finally {
627+ openAiPrototype . prototype . chat = originalChat
628+ }
629+ } )
630+
631+ it ( "maps OpenRouter cache read and write usage tokens" , async ( ) => {
632+ const handler = new OpenRouterHandler ( mockOptions )
633+
634+ const mockStream = asyncStreamFrom ( [
635+ {
636+ id : "test-id" ,
637+ choices : [ { delta : { } } ] ,
638+ usage : {
639+ prompt_tokens : 10 ,
640+ completion_tokens : 2 ,
641+ prompt_tokens_details : { cached_tokens : 6 , cache_write_tokens : 4 } ,
642+ } ,
643+ } ,
644+ ] )
645+ const mockCreate = vitest . fn ( ) . mockResolvedValue ( mockStream )
646+ const chatStub = { completions : { create : mockCreate } }
647+ const openAiPrototype = OpenAI as unknown as { prototype : { chat ?: unknown } }
648+ const originalChat = openAiPrototype . prototype . chat
649+ openAiPrototype . prototype . chat = chatStub
650+
651+ try {
652+ const chunks = await collectStream (
653+ handler . createMessage ( "test system" , [ { role : "user" , content : "test message" } ] ) ,
654+ )
655+
656+ expect ( chunks ) . toContainEqual ( {
657+ type : "usage" ,
658+ inputTokens : 10 ,
659+ outputTokens : 2 ,
660+ cacheReadTokens : 6 ,
661+ cacheWriteTokens : 4 ,
662+ totalCost : 0 ,
663+ } )
664+ } finally {
665+ openAiPrototype . prototype . chat = originalChat
666+ }
667+ } )
668+
669+ it ( "leaves cache token fields unset when OpenRouter reports no cache usage" , async ( ) => {
670+ const handler = new OpenRouterHandler ( mockOptions )
671+
672+ const mockStream = asyncStreamFrom ( [
673+ {
674+ id : "test-id" ,
675+ choices : [ { delta : { } } ] ,
676+ usage : { prompt_tokens : 10 , completion_tokens : 2 } ,
677+ } ,
678+ ] )
679+ const mockCreate = vitest . fn ( ) . mockResolvedValue ( mockStream )
680+ const chatStub = { completions : { create : mockCreate } }
681+ const openAiPrototype = OpenAI as unknown as { prototype : { chat ?: unknown } }
682+ const originalChat = openAiPrototype . prototype . chat
683+ openAiPrototype . prototype . chat = chatStub
684+
685+ try {
686+ const chunks = await collectStream (
687+ handler . createMessage ( "test system" , [ { role : "user" , content : "test message" } ] ) ,
688+ )
689+
690+ const usageChunk = chunks . find ( ( chunk ) => chunk . type === "usage" )
691+ expect ( usageChunk ) . toMatchObject ( { cacheReadTokens : undefined , cacheWriteTokens : undefined } )
692+ } finally {
693+ openAiPrototype . prototype . chat = originalChat
694+ }
695+ } )
696+
488697 it ( "handles API errors and captures telemetry" , async ( ) => {
489698 const handler = new OpenRouterHandler ( mockOptions )
490699 const mockStream = asyncStreamFrom ( [ { error : { message : "API Error" , code : 500 } } ] )
0 commit comments