@@ -606,6 +606,224 @@ describe("OpenRouterHandler", () => {
606606 await expect ( iteration ) . rejects . toMatchObject ( { name : "AbortError" } )
607607 expect ( chunks ) . toContainEqual ( { type : "text" , text : "first" } )
608608 } )
609+ it ( "excludes reasoning for Gemini 2.5 Pro models by default" , async ( ) => {
610+ const handler = new OpenRouterHandler (
611+ makeApiHandlerOptions ( {
612+ ...mockOptions ,
613+ openRouterModelId : "google/gemini-2.5-pro-preview" ,
614+ } ) ,
615+ )
616+ const mockCreate = vitest
617+ . fn ( )
618+ . mockResolvedValue ( asyncStreamFrom ( [ { id : "1" , choices : [ { delta : { content : "ok" } } ] } ] ) )
619+ const client = handler [ "client" ] as unknown as { chat : { completions : { create : typeof mockCreate } } }
620+ client . chat = { completions : { create : mockCreate } }
621+
622+ const stream = handler . createMessage ( "system" , [ { role : "user" as const , content : "hi" } ] )
623+ await collectStream ( stream )
624+
625+ expect ( mockCreate ) . toHaveBeenCalledWith (
626+ expect . objectContaining ( { reasoning : { exclude : true } } ) ,
627+ expect . any ( Object ) ,
628+ )
629+ } )
630+
631+ it ( "uses user role for the system prompt with DeepSeek R1 models" , async ( ) => {
632+ const handler = new OpenRouterHandler (
633+ makeApiHandlerOptions ( {
634+ ...mockOptions ,
635+ openRouterModelId : "deepseek/deepseek-r1" ,
636+ } ) ,
637+ )
638+ const mockCreate = vitest
639+ . fn ( )
640+ . mockResolvedValue ( asyncStreamFrom ( [ { id : "1" , choices : [ { delta : { content : "ok" } } ] } ] ) )
641+ const client = handler [ "client" ] as unknown as { chat : { completions : { create : typeof mockCreate } } }
642+ client . chat = { completions : { create : mockCreate } }
643+
644+ const stream = handler . createMessage ( "system prompt" , [ { role : "user" as const , content : "hi" } ] )
645+ await collectStream ( stream )
646+
647+ const params = mockCreate . mock . calls [ 0 ] [ 0 ] as { messages : { role : string ; content : unknown } [ ] }
648+ expect ( params . messages [ 0 ] . role ) . toBe ( "user" )
649+ expect ( params . messages . map ( ( m ) => m . role ) ) . not . toContain ( "system" )
650+ } )
651+
652+ it ( "injects a fake encrypted reasoning block for Gemini tool calls without encrypted reasoning" , async ( ) => {
653+ const handler = new OpenRouterHandler (
654+ makeApiHandlerOptions ( {
655+ ...mockOptions ,
656+ openRouterModelId : "google/gemini-2.5-flash" ,
657+ } ) ,
658+ )
659+ const mockCreate = vitest
660+ . fn ( )
661+ . mockResolvedValue ( asyncStreamFrom ( [ { id : "1" , choices : [ { delta : { content : "ok" } } ] } ] ) )
662+ const client = handler [ "client" ] as unknown as { chat : { completions : { create : typeof mockCreate } } }
663+ client . chat = { completions : { create : mockCreate } }
664+
665+ // reasoning_details is an OpenRouter extension field round-tripped on assistant
666+ // messages; the Anthropic SDK types do not include it, hence the structural cast.
667+ const assistantMessage = {
668+ role : "assistant" as const ,
669+ content : [ { type : "tool_use" as const , id : "toolu_01" , name : "get_weather" , input : { city : "SF" } } ] ,
670+ reasoning_details : [ { type : "reasoning.text" , id : "toolu_01" , text : "thinking" , index : 0 } ] ,
671+ }
672+ const stream = handler . createMessage ( "system" , [
673+ assistantMessage as unknown as Anthropic . Messages . MessageParam ,
674+ ] )
675+ await collectStream ( stream )
676+
677+ const params = mockCreate . mock . calls [ 0 ] [ 0 ] as {
678+ messages : {
679+ role : string
680+ tool_calls ?: { id : string } [ ]
681+ reasoning_details ?: { type : string ; id : string ; data : string } [ ]
682+ } [ ]
683+ }
684+ const assistant = params . messages . find ( ( m ) => m . role === "assistant" )
685+ expect ( assistant ?. tool_calls ) . toHaveLength ( 1 )
686+ const encrypted = assistant ?. reasoning_details ?. find ( ( d ) => d . type === "reasoning.encrypted" )
687+ expect ( encrypted ) . toMatchObject ( {
688+ id : "toolu_01" ,
689+ data : "skip_thought_signature_validator" ,
690+ } )
691+ } )
692+
693+ it ( "accumulates and yields reasoning_details from streamed chunks" , async ( ) => {
694+ const handler = new OpenRouterHandler ( mockOptions )
695+ const mockCreate = vitest . fn ( ) . mockResolvedValue (
696+ asyncStreamFrom ( [
697+ { id : "1" , choices : [ { delta : { reasoning : "top-level thinking" } } ] } ,
698+ {
699+ id : "2" ,
700+ choices : [
701+ { delta : { reasoning_details : [ { type : "reasoning.text" , index : 0 , text : "thinking " } ] } } ,
702+ ] ,
703+ } ,
704+ {
705+ id : "3" ,
706+ choices : [
707+ {
708+ delta : {
709+ reasoning_details : [
710+ {
711+ type : "reasoning.text" ,
712+ index : 0 ,
713+ text : "more" ,
714+ id : "r1" ,
715+ format : "google-gemini-v1" ,
716+ signature : "sig" ,
717+ } ,
718+ ] ,
719+ } ,
720+ } ,
721+ ] ,
722+ } ,
723+ {
724+ id : "4" ,
725+ choices : [
726+ { delta : { reasoning_details : [ { type : "reasoning.summary" , index : 1 , summary : "sum" } ] } } ,
727+ ] ,
728+ } ,
729+ { id : "5" , choices : [ { delta : { content : "hello" } } ] } ,
730+ {
731+ id : "6" ,
732+ choices : [
733+ {
734+ delta : {
735+ reasoning_details : [ { type : "reasoning.summary" , index : 1 , summary : " more" } ] ,
736+ } ,
737+ } ,
738+ ] ,
739+ } ,
740+ {
741+ id : "7" ,
742+ choices : [
743+ { delta : { reasoning_details : [ { type : "reasoning.encrypted" , index : 2 , data : "enc-" } ] } } ,
744+ ] ,
745+ } ,
746+ {
747+ id : "8" ,
748+ choices : [
749+ {
750+ delta : {
751+ reasoning_details : [ { type : "reasoning.encrypted" , index : 2 , data : "rypted" } ] ,
752+ } ,
753+ } ,
754+ ] ,
755+ } ,
756+ ] ) ,
757+ )
758+ const client = handler [ "client" ] as unknown as { chat : { completions : { create : typeof mockCreate } } }
759+ client . chat = { completions : { create : mockCreate } }
760+
761+ const chunks = await collectStream (
762+ handler . createMessage ( "system" , [ { role : "user" as const , content : "hi" } ] ) ,
763+ )
764+
765+ expect ( chunks ) . toContainEqual ( { type : "reasoning" , text : "top-level thinking" } )
766+ expect ( chunks ) . toContainEqual ( { type : "reasoning" , text : "thinking " } )
767+ expect ( chunks ) . toContainEqual ( { type : "reasoning" , text : "sum" } )
768+ expect ( chunks ) . toContainEqual ( { type : "reasoning" , text : " more" } )
769+ expect ( chunks ) . toContainEqual ( { type : "text" , text : "hello" } )
770+
771+ const details = handler . getReasoningDetails ( )
772+ expect ( details ) . toHaveLength ( 3 )
773+ expect ( details ?. find ( ( d ) => d . type === "reasoning.summary" ) ?. summary ) . toBe ( "sum more" )
774+ expect ( details ?. find ( ( d ) => d . type === "reasoning.encrypted" ) ?. data ) . toBe ( "enc-rypted" )
775+ } )
776+
777+ it ( "rejects with AbortError when the external signal aborts during request creation" , async ( ) => {
778+ const handler = new OpenRouterHandler ( mockOptions )
779+ const controller = new AbortController ( )
780+
781+ const mockCreate = vitest
782+ . fn ( )
783+ . mockImplementation ( async ( _params : unknown , options ?: { signal ?: AbortSignal } ) => {
784+ // Emulate the OpenAI SDK: the pending request rejects when the signal aborts.
785+ await new Promise < void > ( ( resolve ) => {
786+ if ( options ?. signal ?. aborted ) {
787+ resolve ( )
788+ } else {
789+ options ?. signal ?. addEventListener ( "abort" , ( ) => resolve ( ) , { once : true } )
790+ }
791+ } )
792+ const abortError = new Error ( "The user aborted a request" )
793+ abortError . name = "AbortError"
794+ throw abortError
795+ } )
796+ const client = handler [ "client" ] as unknown as { chat : { completions : { create : typeof mockCreate } } }
797+ client . chat = { completions : { create : mockCreate } }
798+
799+ const metadata = makeCreateMessageMetadata ( { abortSignal : controller . signal } )
800+ const generator = handler . createMessage ( "system" , [ { role : "user" as const , content : "hi" } ] , metadata )
801+
802+ const nextPromise = generator . next ( )
803+ // Let the generator reach the pending create() call, then abort.
804+ await new Promise ( ( resolve ) => setTimeout ( resolve , 10 ) )
805+ controller . abort ( )
806+
807+ await expect ( nextPromise ) . rejects . toMatchObject ( { name : "AbortError" } )
808+ } )
809+
810+ it ( "reports OpenRouter structured errors in createMessage with telemetry" , async ( ) => {
811+ const handler = new OpenRouterHandler ( mockOptions )
812+ const mockCreate = vitest . fn ( ) . mockRejectedValueOnce ( {
813+ error : {
814+ message : "Model not found" ,
815+ code : 404 ,
816+ metadata : { raw : '{"message":"upstream: model not found"}' } ,
817+ } ,
818+ } )
819+ const client = handler [ "client" ] as unknown as { chat : { completions : { create : typeof mockCreate } } }
820+ client . chat = { completions : { create : mockCreate } }
821+
822+ const generator = handler . createMessage ( "system" , [ { role : "user" as const , content : "hi" } ] )
823+
824+ await expect ( generator . next ( ) ) . rejects . toThrow ( / c o m p l e t i o n e r r o r / )
825+ expect ( mockCaptureException ) . toHaveBeenCalledTimes ( 1 )
826+ } )
609827 } )
610828
611829 describe ( "completePrompt" , ( ) => {
@@ -858,5 +1076,74 @@ describe("OpenRouterHandler", () => {
8581076
8591077 await expect ( promise ) . rejects . toMatchObject ( { name : "AbortError" } )
8601078 } )
1079+ it ( "rejects with AbortError when only a timeout is provided and it elapses" , async ( ) => {
1080+ // Non-Anthropic model: also exercises the no-beta-header branch of requestOptions.
1081+ const handler = new OpenRouterHandler (
1082+ makeApiHandlerOptions ( {
1083+ ...mockOptions ,
1084+ openRouterModelId : "openai/gpt-4o" ,
1085+ } ) ,
1086+ )
1087+ const mockCreate = vitest
1088+ . fn ( )
1089+ . mockImplementation ( async ( _params : unknown , options ?: { signal ?: AbortSignal } ) => {
1090+ // Emulate the OpenAI SDK: the in-flight request rejects when the signal times out.
1091+ await new Promise < void > ( ( resolve ) => {
1092+ if ( options ?. signal ?. aborted ) {
1093+ resolve ( )
1094+ } else {
1095+ options ?. signal ?. addEventListener ( "abort" , ( ) => resolve ( ) , { once : true } )
1096+ }
1097+ } )
1098+ const timeoutError = new Error ( "TimeoutError: Request timed out." )
1099+ timeoutError . name = "TimeoutError"
1100+ throw timeoutError
1101+ } )
1102+ const client = handler [ "client" ] as unknown as { chat : { completions : { create : typeof mockCreate } } }
1103+ client . chat = { completions : { create : mockCreate } }
1104+
1105+ await expect ( handler . completePrompt ( "test prompt" , { timeoutMs : 50 } ) ) . rejects . toMatchObject ( {
1106+ name : "AbortError" ,
1107+ } )
1108+ } )
1109+
1110+ it ( "rejects with AbortError when both an abort signal and a timeout are provided" , async ( ) => {
1111+ const handler = new OpenRouterHandler ( mockOptions )
1112+ const controller = new AbortController ( )
1113+
1114+ let requestSignal : AbortSignal | undefined
1115+ const mockCreate = vitest
1116+ . fn ( )
1117+ . mockImplementation ( async ( _params : unknown , options ?: { signal ?: AbortSignal } ) => {
1118+ requestSignal = options ?. signal
1119+ await new Promise < void > ( ( resolve ) => {
1120+ if ( options ?. signal ?. aborted ) {
1121+ resolve ( )
1122+ } else {
1123+ options ?. signal ?. addEventListener ( "abort" , ( ) => resolve ( ) , { once : true } )
1124+ }
1125+ } )
1126+ const abortError = new Error ( "The user aborted a request" )
1127+ abortError . name = "AbortError"
1128+ throw abortError
1129+ } )
1130+ const client = handler [ "client" ] as unknown as { chat : { completions : { create : typeof mockCreate } } }
1131+ client . chat = { completions : { create : mockCreate } }
1132+
1133+ const promise = handler . completePrompt ( "test prompt" , {
1134+ abortSignal : controller . signal ,
1135+ timeoutMs : 100_000 ,
1136+ } )
1137+ controller . abort ( )
1138+
1139+ await expect ( promise ) . rejects . toMatchObject ( { name : "AbortError" } )
1140+ // The SDK received a merged signal (not the caller's signal) plus the timeout.
1141+ expect ( requestSignal ) . toBeDefined ( )
1142+ expect ( requestSignal ) . not . toBe ( controller . signal )
1143+ expect ( mockCreate ) . toHaveBeenCalledWith (
1144+ expect . objectContaining ( { model : expect . any ( String ) } ) ,
1145+ expect . objectContaining ( { timeout : 100_000 } ) ,
1146+ )
1147+ } )
8611148 } )
8621149} )
0 commit comments