Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 197 additions & 2 deletions src/translator/chat-to-responses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ describe("chatCompletionsToResponses (batch)", () => {
const r = chatCompletionsToResponses(chatResp(), "glm-5.2");
expect(r.status).toBe("completed");
expect(r.output[0].type).toBe("message");
expect((r.output[0] as { content: { type: string; text: string }[] }).content[0]).toEqual({ type: "output_text", text: "hello" });
const content = (r.output[0] as unknown as { content: { type: string; text: string; annotations: unknown[] }[] }).content;
expect(content[0]).toEqual({ type: "output_text", text: "hello", annotations: [] });
});

it("carries cache-inclusive Chat usage into the Responses usage details", () => {
Expand All @@ -68,9 +69,25 @@ describe("chatCompletionsToResponses (batch)", () => {

expect(r.usage).toEqual({
input_tokens: 1224,
input_tokens_details: { cached_tokens: 1216 },
output_tokens: 3,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: 1227,
input_tokens_details: { cached_tokens: 1216 },
});
});

it("always emits both usage detail objects even when the Chat usage omits them", () => {
// The Responses client requires input_tokens_details / output_tokens_details
// on the wire — omitting them fails the turn with a serde `missing field`.
const r = chatCompletionsToResponses(chatResp({
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}), "glm-5.2");
expect(r.usage).toEqual({
input_tokens: 10,
input_tokens_details: { cached_tokens: 0 },
output_tokens: 5,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: 15,
});
});

Expand Down Expand Up @@ -334,3 +351,181 @@ describe("streaming", () => {
}
});
});

// ─────────────────────────────────────────────
// Client-required field audit (async-openai ResponseUsage / OutputTextContent /
// ReasoningItem / SummaryTextContent / FunctionToolCall). Every field checked
// here is deserialized WITHOUT a serde default by the Responses client: an
// absent field fails the whole turn (`missing field \`x\``). Keep these in
// sync with the wire contract the client enforces.
// Target events are asserted to EXIST first — a filter-based loop that matches
// nothing would pass vacuously and stop guarding the contract it pins.
// ─────────────────────────────────────────────
describe("client-required wire fields", () => {
it("stream: every output_text part carries text and annotations", () => {
const state = newResponsesStreamState("glm-5.2");
const events = [
...chatChunkToResponsesEvents(chunk({ choices: [{ index: 0, delta: { content: "hi" } }] }), state),
...finalizeResponsesStream(state),
];
const itemEvents = events.filter(
(e) => (e.type === "response.output_item.added" || e.type === "response.output_item.done") && e.item.type === "message",
);
const partEvents = events.filter(
(e) => e.type === "response.content_part.added" || e.type === "response.content_part.done",
);
// The target events must be present — otherwise the assertions below are vacuous.
expect(itemEvents.length).toBeGreaterThanOrEqual(2);
expect(partEvents.length).toBe(2);
for (const e of itemEvents) {
const content = (e.item as unknown as { content: Record<string, unknown>[] }).content;
for (const part of content) {
expect(part).toHaveProperty("text");
expect(part).toHaveProperty("annotations");
}
}
for (const e of partEvents) {
if (e.type !== "response.content_part.added" && e.type !== "response.content_part.done") continue;
expect(e.part).toHaveProperty("text");
expect(e.part).toHaveProperty("annotations");
}
// Terminal `response.output` message carries the same fields.
const terminal = events.find(e => e.type === "response.completed");
if (terminal?.type !== "response.completed") throw new Error("missing completed event");
const outMsg = terminal.response.output.find(i => i.type === "message") as unknown as { content: Record<string, unknown>[] };
expect(outMsg).toBeDefined();
for (const part of outMsg.content) {
expect(part).toHaveProperty("text");
expect(part).toHaveProperty("annotations");
}
});

it("stream: reasoning item always carries summary, summary parts always carry text", () => {
const state = newResponsesStreamState("glm-5.2");
const events = [
...chatChunkToResponsesEvents(chunk({ choices: [{ index: 0, delta: { reasoning_content: "thinking" } }] }), state),
...chatChunkToResponsesEvents(chunk({ choices: [{ index: 0, delta: { content: "done" }, finish_reason: "stop" }] }), state),
...finalizeResponsesStream(state),
];
const itemEvents = events.filter(
(e) => (e.type === "response.output_item.added" || e.type === "response.output_item.done") && e.item.type === "reasoning",
);
const partEvents = events.filter(
(e) => e.type === "response.reasoning_summary_part.added" || e.type === "response.reasoning_summary_part.done",
);
expect(itemEvents.length).toBe(2);
expect(partEvents.length).toBe(2);
for (const e of itemEvents) {
const summary = (e.item as unknown as { summary?: unknown[] }).summary;
expect(summary).toBeInstanceOf(Array);
}
for (const e of partEvents) {
if (e.type !== "response.reasoning_summary_part.added" && e.type !== "response.reasoning_summary_part.done") continue;
expect(e.part).toHaveProperty("text");
}
// Terminal `response.output` reasoning item carries a non-empty summary with text.
const terminal = events.find(e => e.type === "response.completed");
if (terminal?.type !== "response.completed") throw new Error("missing completed event");
const outReasoning = terminal.response.output.find(i => i.type === "reasoning") as unknown as { summary: Record<string, unknown>[] };
expect(outReasoning).toBeDefined();
expect(outReasoning.summary.length).toBeGreaterThan(0);
expect(outReasoning.summary[0]).toHaveProperty("text");
});

it("stream: function_call items always carry arguments (plain and namespaced)", () => {
const state = newResponsesStreamState("glm-5.2", {
meta: { customToolNames: new Set(), namespaceMap: new Map([["gmail__send", { namespace: "gmail", name: "send" }]]), hasToolSearch: false },
});
const events = [
...chatChunkToResponsesEvents(chunk({ choices: [{ index: 0, delta: { tool_calls: [
{ index: 0, id: "c0", function: { name: "fn", arguments: "{\"a\":1}" } },
] } }] }), state),
...chatChunkToResponsesEvents(chunk({ choices: [{ index: 0, delta: { tool_calls: [
{ index: 1, id: "c1", function: { name: "gmail__send", arguments: "{}" } },
] } }] }), state),
...finalizeResponsesStream(state),
];
const added = events.filter((e) => e.type === "response.output_item.added" && e.item.type === "function_call");
const done = events.filter((e) => e.type === "response.output_item.done" && e.item.type === "function_call");
// Both the plain and the namespaced branch must be exercised.
expect(added.length).toBe(2);
expect(done.length).toBe(2);
for (const e of added) {
const args = (e.item as unknown as { arguments?: string }).arguments;
expect(args).toBeTypeOf("string");
}
for (const e of done) {
const args = (e.item as unknown as { arguments?: string }).arguments;
expect(args).toBeTypeOf("string");
expect(args!.length).toBeGreaterThan(0);
}
const namespaced = done.find(e => (e.item as unknown as { namespace?: string }).namespace === "gmail");
expect(namespaced).toBeDefined();
// Terminal `response.output` function_call items carry final arguments.
const terminal = events.find(e => e.type === "response.completed");
if (terminal?.type !== "response.completed") throw new Error("missing completed event");
const outCalls = terminal.response.output.filter(i => i.type === "function_call");
expect(outCalls.length).toBe(2);
for (const call of outCalls) {
const args = (call as unknown as { arguments?: string }).arguments;
expect(args).toBeTypeOf("string");
expect(args!.length).toBeGreaterThan(0);
}
});

it("stream: terminal usage always carries both detail objects", () => {
const state = newResponsesStreamState("glm-5.2");
// Upstream Chat usage WITHOUT any details — the shape that used to fail.
const usage = { prompt_tokens: 7, completion_tokens: 3, total_tokens: 10 };
chatChunkToResponsesEvents(chunk({ usage, choices: [] }), state);
const terminalEvents = finalizeResponsesStream(state).filter(e => e.type === "response.completed");
expect(terminalEvents.length).toBe(1);
const terminal = terminalEvents[0];
if (terminal.type !== "response.completed") throw new Error("missing completed event");
expect(terminal.response.usage).toEqual({
input_tokens: 7,
input_tokens_details: { cached_tokens: 0 },
output_tokens: 3,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: 10,
});
});

it("stream: preserves non-zero cached_tokens / reasoning_tokens from upstream usage", () => {
const state = newResponsesStreamState("glm-5.2");
const usage = {
prompt_tokens: 1224, completion_tokens: 5, total_tokens: 1229,
prompt_tokens_details: { cached_tokens: 1216 },
completion_tokens_details: { reasoning_tokens: 3 },
};
chatChunkToResponsesEvents(chunk({ usage, choices: [] }), state);
const terminalEvents = finalizeResponsesStream(state).filter(e => e.type === "response.completed");
expect(terminalEvents.length).toBe(1);
const terminal = terminalEvents[0];
if (terminal.type !== "response.completed") throw new Error("missing completed event");
expect(terminal.response.usage).toEqual({
input_tokens: 1224,
input_tokens_details: { cached_tokens: 1216 },
output_tokens: 5,
output_tokens_details: { reasoning_tokens: 3 },
total_tokens: 1229,
});
});

it("batch: reasoning + message + usage fields satisfy the client contract", () => {
const r = chatCompletionsToResponses({
id: "c", object: "chat.completion", created: 1, model: "glm-5.2",
choices: [{ index: 0, finish_reason: "stop", message: {
role: "assistant", content: "answer", reasoning_content: "why",
} }],
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
}, "glm-5.2");
const reasoning = r.output.find(i => i.type === "reasoning") as unknown as { summary: Record<string, unknown>[] };
expect(reasoning.summary[0]).toHaveProperty("text");
const message = r.output.find(i => i.type === "message") as unknown as { content: Record<string, unknown>[] };
expect(message.content[0]).toHaveProperty("annotations");
expect(message.content[0]).toHaveProperty("text");
expect(r.usage?.input_tokens_details).toEqual({ cached_tokens: 0 });
expect(r.usage?.output_tokens_details).toEqual({ reasoning_tokens: 0 });
});
});
43 changes: 32 additions & 11 deletions src/translator/chat-to-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ function chatChoiceToOutputItems(
type: "message",
id: generateItemId(),
role: "assistant",
content: [{ type: "output_text", text }],
content: [{ type: "output_text", text, annotations: [] }],
status: "completed",
});
}
Expand Down Expand Up @@ -194,7 +194,7 @@ function emptyMessageOutput(): ResponsesOutputItem {
type: "message",
id: generateItemId(),
role: "assistant",
content: [{ type: "output_text", text: "" }],
content: [{ type: "output_text", text: "", annotations: [] }],
status: "completed",
};
}
Expand Down Expand Up @@ -228,12 +228,26 @@ export function extractCustomToolInput(rawArgs: string): string {
}

function chatUsageToResponsesUsage(u: OpenAIUsage): ResponsesUsage {
// The Responses client (async-openai) deserializes ResponseUsage with ALL
// five fields required — an absent `input_tokens_details` / `output_tokens_details`
// fails the whole turn with `missing field \`input_tokens_details\``. Both detail
// objects (and their inner counts) are therefore always emitted.
//
// SEMANTICS OF 0: an emitted `cached_tokens: 0` / `reasoning_tokens: 0`
// means "upstream did not report this breakdown", NOT a measured zero.
// The Anthropic translation layer only reports cache buckets when the
// upstream sends them and never reports reasoning tokens, so responses

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

非阻塞:这里和 ResponsesUsage 的注释写成“0 表示未上报,而不是实测为 0”过于绝对。代码也会原样保留上游明确上报的 0;建议与 PR 描述一致,写成“0 可能表示未上报,也可能是上游报告的 0,无法区分”。

// containing reasoning can legitimately show `reasoning_tokens: 0`.
// This fallback keeps the three known totals (input/output/total) instead
// of omitting the whole usage object — a deliberate tradeoff, and a
// semantic change from "field absent" to "field present with 0" for
// clients that consume these numbers.
return {
input_tokens: u.prompt_tokens ?? 0,
input_tokens_details: { cached_tokens: u.prompt_tokens_details?.cached_tokens ?? 0 },
Comment thread
Catapult291 marked this conversation as resolved.
Resolved
output_tokens: u.completion_tokens ?? 0,
output_tokens_details: { reasoning_tokens: u.completion_tokens_details?.reasoning_tokens ?? 0 },
total_tokens: u.total_tokens ?? (u.prompt_tokens ?? 0) + (u.completion_tokens ?? 0),
...(u.prompt_tokens_details?.cached_tokens ? { input_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } } : {}),
...(u.completion_tokens_details?.reasoning_tokens !== undefined ? { output_tokens_details: { reasoning_tokens: u.completion_tokens_details.reasoning_tokens } } : {}),
};
}

Expand Down Expand Up @@ -450,6 +464,8 @@ function toolCallAddedItem(
call_id: entry.id,
name: ns.name,
namespace: ns.namespace,
// arguments is required on FunctionToolCall; deltas stream afterwards.
arguments: "",
status: "in_progress",
};
}
Expand All @@ -458,6 +474,7 @@ function toolCallAddedItem(
id: entry.itemId,
call_id: entry.id,
name: entry.name,
arguments: "",
status: "in_progress",
};
}
Expand Down Expand Up @@ -563,14 +580,18 @@ function ensureReasoningItem(state: ResponsesStreamState): ResponsesStreamEvent[
seq(state, {
type: "response.output_item.added",
output_index: state.reasoningIndex,
item: { type: "reasoning", id: state.reasoningItemId, status: "in_progress" },
// `summary` is required on ReasoningItem (async-openai deserializes it
// with no default) — emit an empty list until the part closes.
item: { type: "reasoning", id: state.reasoningItemId, summary: [], status: "in_progress" },
}),
seq(state, {
type: "response.reasoning_summary_part.added",
output_index: state.reasoningIndex,
summary_index: 0,
item_id: state.reasoningItemId,
part: { type: "summary_text" },
// SummaryTextContent.text is required — the part opens with "" and the
// text arrives via reasoning_summary_text.delta.
part: { type: "summary_text", text: "" },
}),
];
}
Expand Down Expand Up @@ -645,7 +666,7 @@ function ensureMessageItem(state: ResponsesStreamState): ResponsesStreamEvent[]
id: state.messageItemId,
role: "assistant",
status: "in_progress",
content: [{ type: "output_text" }],
content: [{ type: "output_text", text: "", annotations: [] }],
},
})];
}
Expand All @@ -658,7 +679,7 @@ function ensureTextPart(state: ResponsesStreamState): ResponsesStreamEvent[] {
output_index: state.messageIndex,
content_index: 0,
item_id: state.messageItemId,
part: { type: "output_text", text: "" },
part: { type: "output_text", text: "", annotations: [] },
})];
}

Expand All @@ -678,7 +699,7 @@ function closeMessageItem(state: ResponsesStreamState): ResponsesStreamEvent[] {
output_index: state.messageIndex,
content_index: 0,
item_id: state.messageItemId,
part: { type: "output_text", text: state.text },
part: { type: "output_text", text: state.text, annotations: [] },
}));
}
events.push(seq(state, {
Expand All @@ -689,7 +710,7 @@ function closeMessageItem(state: ResponsesStreamState): ResponsesStreamEvent[] {
id: state.messageItemId,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: state.text }],
content: [{ type: "output_text", text: state.text, annotations: [] }],
},
}));
return events;
Expand Down Expand Up @@ -811,7 +832,7 @@ function buildFinalOutput(state: ResponsesStreamState): ResponsesOutputItem[] {
type: "message",
id: state.messageItemId ?? generateItemId(),
role: "assistant",
content: [{ type: "output_text", text: state.text }],
content: [{ type: "output_text", text: state.text, annotations: [] }],
status: "completed",
});
}
Expand Down
21 changes: 18 additions & 3 deletions src/translator/responses-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ export interface ResponsesContentPart {
filename?: string;
}

/**
* An `output_text` part inside a Responses output message. `annotations` is
* required on the wire (the Responses client deserializes OutputTextContent
* with no serde default) — always emit it, as `[]` when empty.
*/
export interface ResponsesOutputTextPart {
type: "output_text";
text: string;
annotations: unknown[];
}

/** Tool definitions on a Responses request. */
export type ResponsesTool =
| { type: "function"; name: string; description?: string; parameters?: Record<string, unknown>; strict?: boolean }
Expand Down Expand Up @@ -97,10 +108,14 @@ export interface ResponsesRequest {

export interface ResponsesUsage {
input_tokens: number;
// Required on the wire: the Responses client treats both detail objects —
// and their inner token counts — as mandatory (serde `missing field`
// otherwise) — always emit them. `0` means "absent upstream", not a
// measured count (see chatUsageToResponsesUsage).
input_tokens_details: { cached_tokens: number };
output_tokens: number;
output_tokens_details: { reasoning_tokens: number };
total_tokens: number;
input_tokens_details?: { cached_tokens?: number };
output_tokens_details?: { reasoning_tokens?: number };
}

interface ResponsesIncompleteDetails {
Expand All @@ -109,7 +124,7 @@ interface ResponsesIncompleteDetails {

/** An output item in a completed Responses payload. */
export type ResponsesOutputItem =
| { type: "message"; id?: string; role: "assistant"; content: ResponsesContentPart[]; status?: string; [k: string]: unknown }
| { type: "message"; id?: string; role: "assistant"; content: ResponsesOutputTextPart[]; status?: string; [k: string]: unknown }
| { type: "reasoning"; id?: string; summary?: ResponsesContentPart[]; content?: ResponsesContentPart[]; status?: string; [k: string]: unknown }
| { type: "function_call"; id?: string; call_id: string; name: string; arguments: string; status?: string; namespace?: string; [k: string]: unknown }
| { type: "custom_tool_call"; id?: string; call_id: string; name: string; input: string; status?: string; [k: string]: unknown }
Expand Down
Loading