diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index 8630cc0dd79..c0d0947284a 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -211,6 +211,27 @@ function toolCallsToItems( } } +function legacyFunctionCallToItem( + value: unknown, + input: Rec[], + knownNameByCallId: Map, + awaitingToolResult: Set, + sequence: number, +): { callId: string; name: string } | null { + if (value === undefined) return null; + if (!isRec(value) || typeof value.name !== "string" || value.name.length === 0) { + throw new ChatCompletionsRequestError("assistant function_call requires a name"); + } + const args = typeof value.arguments === "string" + ? value.arguments + : JSON.stringify(value.arguments ?? {}); + const callId = `call_legacy_${String(sequence).padStart(4, "0")}`; + knownNameByCallId.set(callId, value.name); + awaitingToolResult.add(callId); + input.push({ type: "function_call", call_id: callId, name: value.name, arguments: args }); + return { callId, name: value.name }; +} + function toolsToResponses(tools: unknown): Rec[] | undefined { if (!Array.isArray(tools) || tools.length === 0) return undefined; const out: Rec[] = []; @@ -243,6 +264,24 @@ function toolsToResponses(tools: unknown): Rec[] | undefined { return out.length > 0 ? out : undefined; } +function legacyFunctionsToResponses(functions: unknown): Rec[] | undefined { + if (functions === undefined) return undefined; + if (!Array.isArray(functions)) throw new ChatCompletionsRequestError("functions must be an array"); + const out: Rec[] = []; + for (const raw of functions) { + if (!isRec(raw) || typeof raw.name !== "string" || raw.name.length === 0) { + throw new ChatCompletionsRequestError("functions entries require a name"); + } + out.push({ + type: "function", + name: raw.name, + ...(typeof raw.description === "string" ? { description: raw.description } : {}), + ...(isRec(raw.parameters) ? { parameters: raw.parameters } : {}), + }); + } + return out.length > 0 ? out : undefined; +} + function toolChoiceToResponses(choice: unknown, body: Rec): void { if (choice === undefined || choice === null) return; if (choice === "auto" || choice === "none" || choice === "required") { @@ -269,6 +308,18 @@ function toolChoiceToResponses(choice: unknown, body: Rec): void { } } +function legacyFunctionChoiceToResponses(choice: unknown, body: Rec): void { + if (choice === undefined || choice === null) return; + if (choice === "auto" || choice === "none") { + body.tool_choice = choice; + return; + } + if (!isRec(choice) || typeof choice.name !== "string" || choice.name.length === 0) { + throw new ChatCompletionsRequestError("function_call requires auto, none, or a function name"); + } + body.tool_choice = { type: "function", name: choice.name }; +} + /** * Chat Completions nests the subset under `allowed_tools`, Responses carries `mode`/`tools` * on the choice itself, and each entry names its tool under a member keyed by its own type @@ -389,6 +440,8 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { // Recover replace-style tool calls incrementally instead of rebuilding the // call-id index from the entire translated transcript for every message. const knownNameByCallId = new Map(); + const legacyAwaiting: Array<{ callId: string; name: string }> = []; + let legacyCallSequence = 0; // Tool calls whose result has not arrived yet. Several adapters need a call and its output // to stay adjacent — Kiro refuses an interrupted pair (src/adapters/kiro/payload.ts) and the // Anthropic and Google mappers synthesize a missing result — so an instruction that arrives @@ -405,6 +458,7 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { const beginConversationTurn = (): void => { releaseHeldInstructions(); awaitingToolResult.clear(); + legacyAwaiting.length = 0; }; for (const msg of raw.messages) { @@ -462,6 +516,16 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { if (msg.tool_calls !== undefined) { toolCallsToItems(msg.tool_calls, input, knownNameByCallId, awaitingToolResult); } + if (msg.function_call !== undefined && msg.function_call !== null) { + const call = legacyFunctionCallToItem( + msg.function_call, + input, + knownNameByCallId, + awaitingToolResult, + ++legacyCallSequence, + ); + if (call) legacyAwaiting.push(call); + } break; } case "function": { @@ -472,6 +536,17 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { "Legacy function-result image translation is not implemented. Use tool_calls and role:tool with tool_call_id.", ); } + const name = typeof msg.name === "string" ? msg.name : ""; + if (!name) throw new ChatCompletionsRequestError("function messages require a name"); + const pendingIndex = legacyAwaiting.findIndex(call => call.name === name); + if (pendingIndex < 0) { + throw new ChatCompletionsRequestError(`function result has no pending call named ${name}`); + } + const [call] = legacyAwaiting.splice(pendingIndex, 1); + const output = contentToText(msg.content); + input.push({ type: "function_call_output", call_id: call!.callId, output }); + awaitingToolResult.delete(call!.callId); + if (awaitingToolResult.size === 0) releaseHeldInstructions(); break; } case "tool": { @@ -507,9 +582,13 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { if (systemParts.length > 0) body.instructions = systemParts.join("\n\n"); - const tools = toolsToResponses(raw.tools); - if (tools) body.tools = tools; - toolChoiceToResponses(raw.tool_choice, body); + const tools = [ + ...(toolsToResponses(raw.tools) ?? []), + ...(legacyFunctionsToResponses(raw.functions) ?? []), + ]; + if (tools.length > 0) body.tools = tools; + if (raw.tool_choice !== undefined) toolChoiceToResponses(raw.tool_choice, body); + else legacyFunctionChoiceToResponses(raw.function_call, body); const maxTokens = typeof raw.max_completion_tokens === "number" ? raw.max_completion_tokens diff --git a/structure/INDEX.md b/structure/INDEX.md index 79cff1af5d1..9a97823fe9b 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -111,7 +111,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/adapters/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/cursor.md`](providers/cursor.md)
[`providers/chat-compat.md`](providers/chat-compat.md)
[`adapters/registry.md`](adapters/registry.md) | | `src/bridge.ts` | [`transports/responses.md`](transports/responses.md) | | `src/bridge/` | [`transports/responses.md`](transports/responses.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md) | -| `src/chat/` | [`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md) | +| `src/chat/` | [`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | | `src/claude/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/cli.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | diff --git a/structure/decisions/ADR-0111-legacy-chat-function-history.md b/structure/decisions/ADR-0111-legacy-chat-function-history.md new file mode 100644 index 00000000000..55b5c3b7c90 --- /dev/null +++ b/structure/decisions/ADR-0111-legacy-chat-function-history.md @@ -0,0 +1,12 @@ +# ADR-0111 — decision recorded under "Chat Compatibility" + +- Contract owner: [providers/chat-compat.md](../providers/chat-compat.md) + +## Decision record + +- 목적과 의도: Preserve complete legacy Chat function declarations, assistant calls, and textual results when translating to the Responses protocol. +- 기존 구현 및 제약 조건: Modern `tools` and `tool_calls` were translated, but top-level `functions`, assistant `function_call`, and text `role: function` messages were omitted. Legacy calls carry no call ID, while Responses requires one and downstream adapters require call/result adjacency. +- 검토한 주요 대안: Reject every legacy request; translate declarations only; infer results by transcript position alone; or assign local call IDs and pair pending results by their declared function name. +- 선택한 방식: Translate legacy declarations into function tools, legacy selection into `tool_choice`, assign bounded sequential call IDs to assistant calls, and resolve each textual function result against the pending same-name call. Orphans and malformed shapes fail explicitly; legacy image results retain their existing explicit refusal. +- 다른 대안 대신 이 방식을 선택한 이유: Declaration-only translation still loses executed history, while silent positional pairing can attach a result to the wrong call. Name-bound pending calls preserve the legacy contract without inventing provider identity. +- 장점, 단점 및 영향: Responses providers receive the full executed exchange and no text result disappears. Synthetic IDs are request-local, and ambiguous or orphaned legacy histories now return a clear client error instead of being forwarded incompletely. diff --git a/structure/manifest.json b/structure/manifest.json index a4349938e8e..a5ea9788ccc 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -346,6 +346,7 @@ "scope": "Cross-vendor Chat Completions behavior: reasoning, tool results, structured output, parallel tools.", "documents": [ "src/adapters/", + "src/chat/", "src/responses/" ] }, diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index a721794ee23..02d928fcd4a 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -498,6 +498,14 @@ reasoning. > Decision record: [ADR-0068](../decisions/ADR-0068-reasoning-display-parity-hidethinkingsummary.md) +`src/chat/inbound.ts` translates legacy Chat `functions`, assistant `function_call`, and textual +`role: "function"` results as one Responses function exchange. Missing or null assistant +`function_call` fields mean no call and preserve ordinary assistant text. The translator assigns +bounded sequential call IDs and pairs results by function name; malformed or orphan results fail +explicitly, and image-bearing legacy results remain unsupported rather than losing media. + +> Decision record: [ADR-0111](../decisions/ADR-0111-legacy-chat-function-history.md) + ## Chat streamed tool-call identity `src/adapters/openai-chat.ts` retains a call's first observed non-negative safe integer diff --git a/tests/responses/chat-media-translation.test.ts b/tests/responses/chat-media-translation.test.ts index d37c27ec871..06406b272b3 100644 --- a/tests/responses/chat-media-translation.test.ts +++ b/tests/responses/chat-media-translation.test.ts @@ -74,6 +74,56 @@ describe("Chat media stays native or fails explicitly at translation", () => { expect(() => chatCompletionsToResponsesBody(raw)).toThrow("Legacy function-result image translation is not implemented"); }); + test("legacy declarations, calls and textual results translate as one paired tool exchange", () => { + const translated = chatCompletionsToResponsesBody({ + model: "model", + functions: [{ name: "lookup", description: "Look up a value", parameters: { + type: "object", properties: { key: { type: "string" } }, required: ["key"], + } }], + function_call: { name: "lookup" }, + messages: [ + { role: "user", content: "Find it." }, + { role: "assistant", content: null, function_call: { name: "lookup", arguments: '{"key":"answer"}' } }, + { role: "function", name: "lookup", content: "RESULT_42" }, + { role: "assistant", content: "The result is 42." }, + ], + }); + + expect(translated.tools).toEqual([{ + type: "function", name: "lookup", description: "Look up a value", + parameters: { type: "object", properties: { key: { type: "string" } }, required: ["key"] }, + }]); + expect(translated.tool_choice).toEqual({ type: "function", name: "lookup" }); + const input = translated.input as Array>; + const call = input.find(item => item.type === "function_call")!; + const output = input.find(item => item.type === "function_call_output")!; + expect(call).toMatchObject({ name: "lookup", arguments: '{"key":"answer"}' }); + expect(output).toEqual({ type: "function_call_output", call_id: call.call_id, output: "RESULT_42" }); + expect(input).toContainEqual({ + type: "message", role: "assistant", + content: [{ type: "output_text", text: "The result is 42." }], + }); + }); + + test("a null legacy function call preserves a textual assistant message", () => { + const translated = chatCompletionsToResponsesBody({ + model: "model", + messages: [{ role: "assistant", content: "The result is 42.", function_call: null }], + }); + + expect(translated.input).toEqual([{ + type: "message", role: "assistant", + content: [{ type: "output_text", text: "The result is 42." }], + }]); + }); + + test("an orphan legacy function result is rejected instead of silently discarded", () => { + expect(() => chatCompletionsToResponsesBody({ + model: "model", + messages: [{ role: "user", content: "go" }, { role: "function", name: "lookup", content: "orphan" }], + })).toThrow("function result has no pending call named lookup"); + }); + test("plain text mentioning an attachment is not treated as one", () => { const text = JSON.stringify([...media, INLINE_FILE]); const out = chatCompletionsToResponsesBody({ model: "model", messages: [{ role: "user", content: text }] });