@@ -53,7 +53,8 @@ describe("chatCompletionsToResponses (batch)", () => {
5353 const r = chatCompletionsToResponses ( chatResp ( ) , "glm-5.2" ) ;
5454 expect ( r . status ) . toBe ( "completed" ) ;
5555 expect ( r . output [ 0 ] . type ) . toBe ( "message" ) ;
56- expect ( ( r . output [ 0 ] as { content : { type : string ; text : string } [ ] } ) . content [ 0 ] ) . toEqual ( { type : "output_text" , text : "hello" } ) ;
56+ const content = ( r . output [ 0 ] as unknown as { content : { type : string ; text : string ; annotations : unknown [ ] } [ ] } ) . content ;
57+ expect ( content [ 0 ] ) . toEqual ( { type : "output_text" , text : "hello" , annotations : [ ] } ) ;
5758 } ) ;
5859
5960 it ( "carries cache-inclusive Chat usage into the Responses usage details" , ( ) => {
@@ -68,9 +69,25 @@ describe("chatCompletionsToResponses (batch)", () => {
6869
6970 expect ( r . usage ) . toEqual ( {
7071 input_tokens : 1224 ,
72+ input_tokens_details : { cached_tokens : 1216 } ,
7173 output_tokens : 3 ,
74+ output_tokens_details : { reasoning_tokens : 0 } ,
7275 total_tokens : 1227 ,
73- input_tokens_details : { cached_tokens : 1216 } ,
76+ } ) ;
77+ } ) ;
78+
79+ it ( "always emits both usage detail objects even when the Chat usage omits them" , ( ) => {
80+ // The Responses client requires input_tokens_details / output_tokens_details
81+ // on the wire — omitting them fails the turn with a serde `missing field`.
82+ const r = chatCompletionsToResponses ( chatResp ( {
83+ usage : { prompt_tokens : 10 , completion_tokens : 5 , total_tokens : 15 } ,
84+ } ) , "glm-5.2" ) ;
85+ expect ( r . usage ) . toEqual ( {
86+ input_tokens : 10 ,
87+ input_tokens_details : { cached_tokens : 0 } ,
88+ output_tokens : 5 ,
89+ output_tokens_details : { reasoning_tokens : 0 } ,
90+ total_tokens : 15 ,
7491 } ) ;
7592 } ) ;
7693
@@ -334,3 +351,181 @@ describe("streaming", () => {
334351 }
335352 } ) ;
336353} ) ;
354+
355+ // ─────────────────────────────────────────────
356+ // Client-required field audit (async-openai ResponseUsage / OutputTextContent /
357+ // ReasoningItem / SummaryTextContent / FunctionToolCall). Every field checked
358+ // here is deserialized WITHOUT a serde default by the Responses client: an
359+ // absent field fails the whole turn (`missing field \`x\``). Keep these in
360+ // sync with the wire contract the client enforces.
361+ // Target events are asserted to EXIST first — a filter-based loop that matches
362+ // nothing would pass vacuously and stop guarding the contract it pins.
363+ // ─────────────────────────────────────────────
364+ describe ( "client-required wire fields" , ( ) => {
365+ it ( "stream: every output_text part carries text and annotations" , ( ) => {
366+ const state = newResponsesStreamState ( "glm-5.2" ) ;
367+ const events = [
368+ ...chatChunkToResponsesEvents ( chunk ( { choices : [ { index : 0 , delta : { content : "hi" } } ] } ) , state ) ,
369+ ...finalizeResponsesStream ( state ) ,
370+ ] ;
371+ const itemEvents = events . filter (
372+ ( e ) => ( e . type === "response.output_item.added" || e . type === "response.output_item.done" ) && e . item . type === "message" ,
373+ ) ;
374+ const partEvents = events . filter (
375+ ( e ) => e . type === "response.content_part.added" || e . type === "response.content_part.done" ,
376+ ) ;
377+ // The target events must be present — otherwise the assertions below are vacuous.
378+ expect ( itemEvents . length ) . toBeGreaterThanOrEqual ( 2 ) ;
379+ expect ( partEvents . length ) . toBe ( 2 ) ;
380+ for ( const e of itemEvents ) {
381+ const content = ( e . item as unknown as { content : Record < string , unknown > [ ] } ) . content ;
382+ for ( const part of content ) {
383+ expect ( part ) . toHaveProperty ( "text" ) ;
384+ expect ( part ) . toHaveProperty ( "annotations" ) ;
385+ }
386+ }
387+ for ( const e of partEvents ) {
388+ if ( e . type !== "response.content_part.added" && e . type !== "response.content_part.done" ) continue ;
389+ expect ( e . part ) . toHaveProperty ( "text" ) ;
390+ expect ( e . part ) . toHaveProperty ( "annotations" ) ;
391+ }
392+ // Terminal `response.output` message carries the same fields.
393+ const terminal = events . find ( e => e . type === "response.completed" ) ;
394+ if ( terminal ?. type !== "response.completed" ) throw new Error ( "missing completed event" ) ;
395+ const outMsg = terminal . response . output . find ( i => i . type === "message" ) as unknown as { content : Record < string , unknown > [ ] } ;
396+ expect ( outMsg ) . toBeDefined ( ) ;
397+ for ( const part of outMsg . content ) {
398+ expect ( part ) . toHaveProperty ( "text" ) ;
399+ expect ( part ) . toHaveProperty ( "annotations" ) ;
400+ }
401+ } ) ;
402+
403+ it ( "stream: reasoning item always carries summary, summary parts always carry text" , ( ) => {
404+ const state = newResponsesStreamState ( "glm-5.2" ) ;
405+ const events = [
406+ ...chatChunkToResponsesEvents ( chunk ( { choices : [ { index : 0 , delta : { reasoning_content : "thinking" } } ] } ) , state ) ,
407+ ...chatChunkToResponsesEvents ( chunk ( { choices : [ { index : 0 , delta : { content : "done" } , finish_reason : "stop" } ] } ) , state ) ,
408+ ...finalizeResponsesStream ( state ) ,
409+ ] ;
410+ const itemEvents = events . filter (
411+ ( e ) => ( e . type === "response.output_item.added" || e . type === "response.output_item.done" ) && e . item . type === "reasoning" ,
412+ ) ;
413+ const partEvents = events . filter (
414+ ( e ) => e . type === "response.reasoning_summary_part.added" || e . type === "response.reasoning_summary_part.done" ,
415+ ) ;
416+ expect ( itemEvents . length ) . toBe ( 2 ) ;
417+ expect ( partEvents . length ) . toBe ( 2 ) ;
418+ for ( const e of itemEvents ) {
419+ const summary = ( e . item as unknown as { summary ?: unknown [ ] } ) . summary ;
420+ expect ( summary ) . toBeInstanceOf ( Array ) ;
421+ }
422+ for ( const e of partEvents ) {
423+ if ( e . type !== "response.reasoning_summary_part.added" && e . type !== "response.reasoning_summary_part.done" ) continue ;
424+ expect ( e . part ) . toHaveProperty ( "text" ) ;
425+ }
426+ // Terminal `response.output` reasoning item carries a non-empty summary with text.
427+ const terminal = events . find ( e => e . type === "response.completed" ) ;
428+ if ( terminal ?. type !== "response.completed" ) throw new Error ( "missing completed event" ) ;
429+ const outReasoning = terminal . response . output . find ( i => i . type === "reasoning" ) as unknown as { summary : Record < string , unknown > [ ] } ;
430+ expect ( outReasoning ) . toBeDefined ( ) ;
431+ expect ( outReasoning . summary . length ) . toBeGreaterThan ( 0 ) ;
432+ expect ( outReasoning . summary [ 0 ] ) . toHaveProperty ( "text" ) ;
433+ } ) ;
434+
435+ it ( "stream: function_call items always carry arguments (plain and namespaced)" , ( ) => {
436+ const state = newResponsesStreamState ( "glm-5.2" , {
437+ meta : { customToolNames : new Set ( ) , namespaceMap : new Map ( [ [ "gmail__send" , { namespace : "gmail" , name : "send" } ] ] ) , hasToolSearch : false } ,
438+ } ) ;
439+ const events = [
440+ ...chatChunkToResponsesEvents ( chunk ( { choices : [ { index : 0 , delta : { tool_calls : [
441+ { index : 0 , id : "c0" , function : { name : "fn" , arguments : "{\"a\":1}" } } ,
442+ ] } } ] } ) , state ) ,
443+ ...chatChunkToResponsesEvents ( chunk ( { choices : [ { index : 0 , delta : { tool_calls : [
444+ { index : 1 , id : "c1" , function : { name : "gmail__send" , arguments : "{}" } } ,
445+ ] } } ] } ) , state ) ,
446+ ...finalizeResponsesStream ( state ) ,
447+ ] ;
448+ const added = events . filter ( ( e ) => e . type === "response.output_item.added" && e . item . type === "function_call" ) ;
449+ const done = events . filter ( ( e ) => e . type === "response.output_item.done" && e . item . type === "function_call" ) ;
450+ // Both the plain and the namespaced branch must be exercised.
451+ expect ( added . length ) . toBe ( 2 ) ;
452+ expect ( done . length ) . toBe ( 2 ) ;
453+ for ( const e of added ) {
454+ const args = ( e . item as unknown as { arguments ?: string } ) . arguments ;
455+ expect ( args ) . toBeTypeOf ( "string" ) ;
456+ }
457+ for ( const e of done ) {
458+ const args = ( e . item as unknown as { arguments ?: string } ) . arguments ;
459+ expect ( args ) . toBeTypeOf ( "string" ) ;
460+ expect ( args ! . length ) . toBeGreaterThan ( 0 ) ;
461+ }
462+ const namespaced = done . find ( e => ( e . item as unknown as { namespace ?: string } ) . namespace === "gmail" ) ;
463+ expect ( namespaced ) . toBeDefined ( ) ;
464+ // Terminal `response.output` function_call items carry final arguments.
465+ const terminal = events . find ( e => e . type === "response.completed" ) ;
466+ if ( terminal ?. type !== "response.completed" ) throw new Error ( "missing completed event" ) ;
467+ const outCalls = terminal . response . output . filter ( i => i . type === "function_call" ) ;
468+ expect ( outCalls . length ) . toBe ( 2 ) ;
469+ for ( const call of outCalls ) {
470+ const args = ( call as unknown as { arguments ?: string } ) . arguments ;
471+ expect ( args ) . toBeTypeOf ( "string" ) ;
472+ expect ( args ! . length ) . toBeGreaterThan ( 0 ) ;
473+ }
474+ } ) ;
475+
476+ it ( "stream: terminal usage always carries both detail objects" , ( ) => {
477+ const state = newResponsesStreamState ( "glm-5.2" ) ;
478+ // Upstream Chat usage WITHOUT any details — the shape that used to fail.
479+ const usage = { prompt_tokens : 7 , completion_tokens : 3 , total_tokens : 10 } ;
480+ chatChunkToResponsesEvents ( chunk ( { usage, choices : [ ] } ) , state ) ;
481+ const terminalEvents = finalizeResponsesStream ( state ) . filter ( e => e . type === "response.completed" ) ;
482+ expect ( terminalEvents . length ) . toBe ( 1 ) ;
483+ const terminal = terminalEvents [ 0 ] ;
484+ if ( terminal . type !== "response.completed" ) throw new Error ( "missing completed event" ) ;
485+ expect ( terminal . response . usage ) . toEqual ( {
486+ input_tokens : 7 ,
487+ input_tokens_details : { cached_tokens : 0 } ,
488+ output_tokens : 3 ,
489+ output_tokens_details : { reasoning_tokens : 0 } ,
490+ total_tokens : 10 ,
491+ } ) ;
492+ } ) ;
493+
494+ it ( "stream: preserves non-zero cached_tokens / reasoning_tokens from upstream usage" , ( ) => {
495+ const state = newResponsesStreamState ( "glm-5.2" ) ;
496+ const usage = {
497+ prompt_tokens : 1224 , completion_tokens : 5 , total_tokens : 1229 ,
498+ prompt_tokens_details : { cached_tokens : 1216 } ,
499+ completion_tokens_details : { reasoning_tokens : 3 } ,
500+ } ;
501+ chatChunkToResponsesEvents ( chunk ( { usage, choices : [ ] } ) , state ) ;
502+ const terminalEvents = finalizeResponsesStream ( state ) . filter ( e => e . type === "response.completed" ) ;
503+ expect ( terminalEvents . length ) . toBe ( 1 ) ;
504+ const terminal = terminalEvents [ 0 ] ;
505+ if ( terminal . type !== "response.completed" ) throw new Error ( "missing completed event" ) ;
506+ expect ( terminal . response . usage ) . toEqual ( {
507+ input_tokens : 1224 ,
508+ input_tokens_details : { cached_tokens : 1216 } ,
509+ output_tokens : 5 ,
510+ output_tokens_details : { reasoning_tokens : 3 } ,
511+ total_tokens : 1229 ,
512+ } ) ;
513+ } ) ;
514+
515+ it ( "batch: reasoning + message + usage fields satisfy the client contract" , ( ) => {
516+ const r = chatCompletionsToResponses ( {
517+ id : "c" , object : "chat.completion" , created : 1 , model : "glm-5.2" ,
518+ choices : [ { index : 0 , finish_reason : "stop" , message : {
519+ role : "assistant" , content : "answer" , reasoning_content : "why" ,
520+ } } ] ,
521+ usage : { prompt_tokens : 1 , completion_tokens : 2 , total_tokens : 3 } ,
522+ } , "glm-5.2" ) ;
523+ const reasoning = r . output . find ( i => i . type === "reasoning" ) as unknown as { summary : Record < string , unknown > [ ] } ;
524+ expect ( reasoning . summary [ 0 ] ) . toHaveProperty ( "text" ) ;
525+ const message = r . output . find ( i => i . type === "message" ) as unknown as { content : Record < string , unknown > [ ] } ;
526+ expect ( message . content [ 0 ] ) . toHaveProperty ( "annotations" ) ;
527+ expect ( message . content [ 0 ] ) . toHaveProperty ( "text" ) ;
528+ expect ( r . usage ?. input_tokens_details ) . toEqual ( { cached_tokens : 0 } ) ;
529+ expect ( r . usage ?. output_tokens_details ) . toEqual ( { reasoning_tokens : 0 } ) ;
530+ } ) ;
531+ } ) ;
0 commit comments