@@ -265,6 +265,199 @@ describe("OpenRouterHandler", () => {
265265 const generator = handler . createMessage ( "test" , [ ] )
266266 await expect ( generator . next ( ) ) . rejects . toThrow ( "OpenRouter API Error 500: API Error" )
267267 } )
268+
269+ it ( "parses <think> blocks correctly" , async ( ) => {
270+ const handler = new OpenRouterHandler ( mockOptions )
271+ const mockStream = {
272+ async * [ Symbol . asyncIterator ] ( ) {
273+ yield {
274+ id : "test-id" ,
275+ choices : [ { delta : { content : "Before <think>This is thinking content</think> After" } } ] ,
276+ }
277+ yield {
278+ id : "test-id" ,
279+ choices : [ { delta : { } } ] ,
280+ usage : { prompt_tokens : 10 , completion_tokens : 20 } ,
281+ }
282+ } ,
283+ }
284+
285+ const mockCreate = vitest . fn ( ) . mockResolvedValue ( mockStream )
286+ ; ( OpenAI as any ) . prototype . chat = {
287+ completions : { create : mockCreate } ,
288+ } as any
289+
290+ const generator = handler . createMessage ( "test" , [ ] )
291+ const chunks = [ ]
292+
293+ for await ( const chunk of generator ) {
294+ chunks . push ( chunk )
295+ }
296+
297+ // Should have 3 text/reasoning chunks and 1 usage chunk
298+ expect ( chunks ) . toHaveLength ( 4 )
299+ expect ( chunks [ 0 ] ) . toEqual ( { type : "text" , text : "Before " } )
300+ expect ( chunks [ 1 ] ) . toEqual ( { type : "reasoning" , text : "This is thinking content" } )
301+ expect ( chunks [ 2 ] ) . toEqual ( { type : "text" , text : " After" } )
302+ expect ( chunks [ 3 ] ) . toEqual ( {
303+ type : "usage" ,
304+ inputTokens : 10 ,
305+ outputTokens : 20 ,
306+ cacheReadTokens : undefined ,
307+ reasoningTokens : undefined ,
308+ totalCost : 0 ,
309+ } )
310+ } )
311+
312+ it ( "parses <tool_call> blocks correctly" , async ( ) => {
313+ const handler = new OpenRouterHandler ( mockOptions )
314+ const mockStream = {
315+ async * [ Symbol . asyncIterator ] ( ) {
316+ yield {
317+ id : "test-id" ,
318+ choices : [
319+ { delta : { content : "Text before <tool_call>Tool call content</tool_call> text after" } } ,
320+ ] ,
321+ }
322+ yield {
323+ id : "test-id" ,
324+ choices : [ { delta : { } } ] ,
325+ usage : { prompt_tokens : 10 , completion_tokens : 20 } ,
326+ }
327+ } ,
328+ }
329+
330+ const mockCreate = vitest . fn ( ) . mockResolvedValue ( mockStream )
331+ ; ( OpenAI as any ) . prototype . chat = {
332+ completions : { create : mockCreate } ,
333+ } as any
334+
335+ const generator = handler . createMessage ( "test" , [ ] )
336+ const chunks = [ ]
337+
338+ for await ( const chunk of generator ) {
339+ chunks . push ( chunk )
340+ }
341+
342+ // Should have 3 text chunks (before, tool call formatted, after) and 1 usage chunk
343+ expect ( chunks ) . toHaveLength ( 4 )
344+ expect ( chunks [ 0 ] ) . toEqual ( { type : "text" , text : "Text before " } )
345+ expect ( chunks [ 1 ] ) . toEqual ( { type : "text" , text : "[Tool Call]: Tool call content" } )
346+ expect ( chunks [ 2 ] ) . toEqual ( { type : "text" , text : " text after" } )
347+ expect ( chunks [ 3 ] ) . toEqual ( {
348+ type : "usage" ,
349+ inputTokens : 10 ,
350+ outputTokens : 20 ,
351+ cacheReadTokens : undefined ,
352+ reasoningTokens : undefined ,
353+ totalCost : 0 ,
354+ } )
355+ } )
356+
357+ it ( "handles nested and multiple XML blocks" , async ( ) => {
358+ const handler = new OpenRouterHandler ( mockOptions )
359+ const mockStream = {
360+ async * [ Symbol . asyncIterator ] ( ) {
361+ yield {
362+ id : "test-id" ,
363+ choices : [
364+ {
365+ delta : {
366+ content : "<think>First think</think> middle <tool_call>Tool usage</tool_call>" ,
367+ } ,
368+ } ,
369+ ] ,
370+ }
371+ yield {
372+ id : "test-id" ,
373+ choices : [ { delta : { content : " <think>Second think</think> end" } } ] ,
374+ }
375+ yield {
376+ id : "test-id" ,
377+ choices : [ { delta : { } } ] ,
378+ usage : { prompt_tokens : 10 , completion_tokens : 20 } ,
379+ }
380+ } ,
381+ }
382+
383+ const mockCreate = vitest . fn ( ) . mockResolvedValue ( mockStream )
384+ ; ( OpenAI as any ) . prototype . chat = {
385+ completions : { create : mockCreate } ,
386+ } as any
387+
388+ const generator = handler . createMessage ( "test" , [ ] )
389+ const chunks = [ ]
390+
391+ for await ( const chunk of generator ) {
392+ chunks . push ( chunk )
393+ }
394+
395+ // Verify all chunks are parsed correctly
396+ expect ( chunks ) . toContainEqual ( { type : "reasoning" , text : "First think" } )
397+ expect ( chunks ) . toContainEqual ( { type : "text" , text : " middle " } )
398+ expect ( chunks ) . toContainEqual ( { type : "text" , text : "[Tool Call]: Tool usage" } )
399+ expect ( chunks ) . toContainEqual ( { type : "text" , text : " " } )
400+ expect ( chunks ) . toContainEqual ( { type : "reasoning" , text : "Second think" } )
401+ expect ( chunks ) . toContainEqual ( { type : "text" , text : " end" } )
402+ expect ( chunks ) . toContainEqual ( {
403+ type : "usage" ,
404+ inputTokens : 10 ,
405+ outputTokens : 20 ,
406+ cacheReadTokens : undefined ,
407+ reasoningTokens : undefined ,
408+ totalCost : 0 ,
409+ } )
410+ } )
411+
412+ it ( "handles incomplete XML blocks across chunks" , async ( ) => {
413+ const handler = new OpenRouterHandler ( mockOptions )
414+ const mockStream = {
415+ async * [ Symbol . asyncIterator ] ( ) {
416+ yield {
417+ id : "test-id" ,
418+ choices : [ { delta : { content : "Start <thi" } } ] ,
419+ }
420+ yield {
421+ id : "test-id" ,
422+ choices : [ { delta : { content : "nk>Thinking content</thi" } } ] ,
423+ }
424+ yield {
425+ id : "test-id" ,
426+ choices : [ { delta : { content : "nk> End" } } ] ,
427+ }
428+ yield {
429+ id : "test-id" ,
430+ choices : [ { delta : { } } ] ,
431+ usage : { prompt_tokens : 10 , completion_tokens : 20 } ,
432+ }
433+ } ,
434+ }
435+
436+ const mockCreate = vitest . fn ( ) . mockResolvedValue ( mockStream )
437+ ; ( OpenAI as any ) . prototype . chat = {
438+ completions : { create : mockCreate } ,
439+ } as any
440+
441+ const generator = handler . createMessage ( "test" , [ ] )
442+ const chunks = [ ]
443+
444+ for await ( const chunk of generator ) {
445+ chunks . push ( chunk )
446+ }
447+
448+ // Should correctly parse the thinking block even when split across chunks
449+ expect ( chunks ) . toContainEqual ( { type : "text" , text : "Start " } )
450+ expect ( chunks ) . toContainEqual ( { type : "reasoning" , text : "Thinking content" } )
451+ expect ( chunks ) . toContainEqual ( { type : "text" , text : " End" } )
452+ expect ( chunks ) . toContainEqual ( {
453+ type : "usage" ,
454+ inputTokens : 10 ,
455+ outputTokens : 20 ,
456+ cacheReadTokens : undefined ,
457+ reasoningTokens : undefined ,
458+ totalCost : 0 ,
459+ } )
460+ } )
268461 } )
269462
270463 describe ( "completePrompt" , ( ) => {
0 commit comments