Skip to content

Commit f8ae566

Browse files
authored
Merge pull request TriDefender#77 from Catapult291/fix/responses-client-required-fields
fix(translator): always emit client-required fields on the Responses wire
2 parents f7607d6 + f758ebc commit f8ae566

3 files changed

Lines changed: 247 additions & 16 deletions

File tree

‎src/translator/chat-to-responses.test.ts‎

Lines changed: 197 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

‎src/translator/chat-to-responses.ts‎

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ function chatChoiceToOutputItems(
120120
type: "message",
121121
id: generateItemId(),
122122
role: "assistant",
123-
content: [{ type: "output_text", text }],
123+
content: [{ type: "output_text", text, annotations: [] }],
124124
status: "completed",
125125
});
126126
}
@@ -194,7 +194,7 @@ function emptyMessageOutput(): ResponsesOutputItem {
194194
type: "message",
195195
id: generateItemId(),
196196
role: "assistant",
197-
content: [{ type: "output_text", text: "" }],
197+
content: [{ type: "output_text", text: "", annotations: [] }],
198198
status: "completed",
199199
};
200200
}
@@ -228,12 +228,26 @@ export function extractCustomToolInput(rawArgs: string): string {
228228
}
229229

230230
function chatUsageToResponsesUsage(u: OpenAIUsage): ResponsesUsage {
231+
// The Responses client (async-openai) deserializes ResponseUsage with ALL
232+
// five fields required — an absent `input_tokens_details` / `output_tokens_details`
233+
// fails the whole turn with `missing field \`input_tokens_details\``. Both detail
234+
// objects (and their inner counts) are therefore always emitted.
235+
//
236+
// SEMANTICS OF 0: an emitted `cached_tokens: 0` / `reasoning_tokens: 0`
237+
// means "upstream did not report this breakdown", NOT a measured zero.
238+
// The Anthropic translation layer only reports cache buckets when the
239+
// upstream sends them and never reports reasoning tokens, so responses
240+
// containing reasoning can legitimately show `reasoning_tokens: 0`.
241+
// This fallback keeps the three known totals (input/output/total) instead
242+
// of omitting the whole usage object — a deliberate tradeoff, and a
243+
// semantic change from "field absent" to "field present with 0" for
244+
// clients that consume these numbers.
231245
return {
232246
input_tokens: u.prompt_tokens ?? 0,
247+
input_tokens_details: { cached_tokens: u.prompt_tokens_details?.cached_tokens ?? 0 },
233248
output_tokens: u.completion_tokens ?? 0,
249+
output_tokens_details: { reasoning_tokens: u.completion_tokens_details?.reasoning_tokens ?? 0 },
234250
total_tokens: u.total_tokens ?? (u.prompt_tokens ?? 0) + (u.completion_tokens ?? 0),
235-
...(u.prompt_tokens_details?.cached_tokens ? { input_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } } : {}),
236-
...(u.completion_tokens_details?.reasoning_tokens !== undefined ? { output_tokens_details: { reasoning_tokens: u.completion_tokens_details.reasoning_tokens } } : {}),
237251
};
238252
}
239253

@@ -450,6 +464,8 @@ function toolCallAddedItem(
450464
call_id: entry.id,
451465
name: ns.name,
452466
namespace: ns.namespace,
467+
// arguments is required on FunctionToolCall; deltas stream afterwards.
468+
arguments: "",
453469
status: "in_progress",
454470
};
455471
}
@@ -458,6 +474,7 @@ function toolCallAddedItem(
458474
id: entry.itemId,
459475
call_id: entry.id,
460476
name: entry.name,
477+
arguments: "",
461478
status: "in_progress",
462479
};
463480
}
@@ -563,14 +580,18 @@ function ensureReasoningItem(state: ResponsesStreamState): ResponsesStreamEvent[
563580
seq(state, {
564581
type: "response.output_item.added",
565582
output_index: state.reasoningIndex,
566-
item: { type: "reasoning", id: state.reasoningItemId, status: "in_progress" },
583+
// `summary` is required on ReasoningItem (async-openai deserializes it
584+
// with no default) — emit an empty list until the part closes.
585+
item: { type: "reasoning", id: state.reasoningItemId, summary: [], status: "in_progress" },
567586
}),
568587
seq(state, {
569588
type: "response.reasoning_summary_part.added",
570589
output_index: state.reasoningIndex,
571590
summary_index: 0,
572591
item_id: state.reasoningItemId,
573-
part: { type: "summary_text" },
592+
// SummaryTextContent.text is required — the part opens with "" and the
593+
// text arrives via reasoning_summary_text.delta.
594+
part: { type: "summary_text", text: "" },
574595
}),
575596
];
576597
}
@@ -645,7 +666,7 @@ function ensureMessageItem(state: ResponsesStreamState): ResponsesStreamEvent[]
645666
id: state.messageItemId,
646667
role: "assistant",
647668
status: "in_progress",
648-
content: [{ type: "output_text" }],
669+
content: [{ type: "output_text", text: "", annotations: [] }],
649670
},
650671
})];
651672
}
@@ -658,7 +679,7 @@ function ensureTextPart(state: ResponsesStreamState): ResponsesStreamEvent[] {
658679
output_index: state.messageIndex,
659680
content_index: 0,
660681
item_id: state.messageItemId,
661-
part: { type: "output_text", text: "" },
682+
part: { type: "output_text", text: "", annotations: [] },
662683
})];
663684
}
664685

@@ -678,7 +699,7 @@ function closeMessageItem(state: ResponsesStreamState): ResponsesStreamEvent[] {
678699
output_index: state.messageIndex,
679700
content_index: 0,
680701
item_id: state.messageItemId,
681-
part: { type: "output_text", text: state.text },
702+
part: { type: "output_text", text: state.text, annotations: [] },
682703
}));
683704
}
684705
events.push(seq(state, {
@@ -689,7 +710,7 @@ function closeMessageItem(state: ResponsesStreamState): ResponsesStreamEvent[] {
689710
id: state.messageItemId,
690711
role: "assistant",
691712
status: "completed",
692-
content: [{ type: "output_text", text: state.text }],
713+
content: [{ type: "output_text", text: state.text, annotations: [] }],
693714
},
694715
}));
695716
return events;
@@ -811,7 +832,7 @@ function buildFinalOutput(state: ResponsesStreamState): ResponsesOutputItem[] {
811832
type: "message",
812833
id: state.messageItemId ?? generateItemId(),
813834
role: "assistant",
814-
content: [{ type: "output_text", text: state.text }],
835+
content: [{ type: "output_text", text: state.text, annotations: [] }],
815836
status: "completed",
816837
});
817838
}

‎src/translator/responses-types.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ export interface ResponsesContentPart {
3939
filename?: string;
4040
}
4141

42+
/**
43+
* An `output_text` part inside a Responses output message. `annotations` is
44+
* required on the wire (the Responses client deserializes OutputTextContent
45+
* with no serde default) — always emit it, as `[]` when empty.
46+
*/
47+
export interface ResponsesOutputTextPart {
48+
type: "output_text";
49+
text: string;
50+
annotations: unknown[];
51+
}
52+
4253
/** Tool definitions on a Responses request. */
4354
export type ResponsesTool =
4455
| { type: "function"; name: string; description?: string; parameters?: Record<string, unknown>; strict?: boolean }
@@ -97,10 +108,14 @@ export interface ResponsesRequest {
97108

98109
export interface ResponsesUsage {
99110
input_tokens: number;
111+
// Required on the wire: the Responses client treats both detail objects —
112+
// and their inner token counts — as mandatory (serde `missing field`
113+
// otherwise) — always emit them. `0` means "absent upstream", not a
114+
// measured count (see chatUsageToResponsesUsage).
115+
input_tokens_details: { cached_tokens: number };
100116
output_tokens: number;
117+
output_tokens_details: { reasoning_tokens: number };
101118
total_tokens: number;
102-
input_tokens_details?: { cached_tokens?: number };
103-
output_tokens_details?: { reasoning_tokens?: number };
104119
}
105120

106121
interface ResponsesIncompleteDetails {
@@ -109,7 +124,7 @@ interface ResponsesIncompleteDetails {
109124

110125
/** An output item in a completed Responses payload. */
111126
export type ResponsesOutputItem =
112-
| { type: "message"; id?: string; role: "assistant"; content: ResponsesContentPart[]; status?: string; [k: string]: unknown }
127+
| { type: "message"; id?: string; role: "assistant"; content: ResponsesOutputTextPart[]; status?: string; [k: string]: unknown }
113128
| { type: "reasoning"; id?: string; summary?: ResponsesContentPart[]; content?: ResponsesContentPart[]; status?: string; [k: string]: unknown }
114129
| { type: "function_call"; id?: string; call_id: string; name: string; arguments: string; status?: string; namespace?: string; [k: string]: unknown }
115130
| { type: "custom_tool_call"; id?: string; call_id: string; name: string; input: string; status?: string; [k: string]: unknown }

0 commit comments

Comments
 (0)