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
85 changes: 82 additions & 3 deletions src/chat/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,27 @@ function toolCallsToItems(
}
}

function legacyFunctionCallToItem(
value: unknown,
input: Rec[],
knownNameByCallId: Map<string, string>,
awaitingToolResult: Set<string>,
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[] = [];
Expand Down Expand Up @@ -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") {
Expand All @@ -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
Expand Down Expand Up @@ -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<string, string>();
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
Expand All @@ -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) {
Expand Down Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion structure/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)<br>[`transports/byte-accounting.md`](transports/byte-accounting.md)<br>[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)<br>[`transports/inventory.md`](transports/inventory.md)<br>[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)<br>[`providers-and-adapters.md`](providers-and-adapters.md)<br>[`providers/cursor.md`](providers/cursor.md)<br>[`providers/chat-compat.md`](providers/chat-compat.md)<br>[`adapters/registry.md`](adapters/registry.md) |
| `src/bridge.ts` | [`transports/responses.md`](transports/responses.md) |
| `src/bridge/` | [`transports/responses.md`](transports/responses.md)<br>[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md) |
| `src/chat/` | [`runtime.md`](runtime.md)<br>[`transports/inventory.md`](transports/inventory.md)<br>[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)<br>[`providers-and-adapters.md`](providers-and-adapters.md) |
| `src/chat/` | [`runtime.md`](runtime.md)<br>[`transports/inventory.md`](transports/inventory.md)<br>[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)<br>[`providers-and-adapters.md`](providers-and-adapters.md)<br>[`providers/chat-compat.md`](providers/chat-compat.md) |
| `src/claude/` | [`runtime.md`](runtime.md)<br>[`clients/claude-desktop.md`](clients/claude-desktop.md) |
| `src/cli.ts` | [`runtime.md`](runtime.md)<br>[`ops/docs-and-release.md`](ops/docs-and-release.md) |
| `src/cli/` | [`runtime.md`](runtime.md)<br>[`config.md`](config.md)<br>[`clients/claude-desktop.md`](clients/claude-desktop.md)<br>[`ops/docs-and-release.md`](ops/docs-and-release.md) |
Expand Down
12 changes: 12 additions & 0 deletions structure/decisions/ADR-0111-legacy-chat-function-history.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions structure/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@
"scope": "Cross-vendor Chat Completions behavior: reasoning, tool results, structured output, parallel tools.",
"documents": [
"src/adapters/",
"src/chat/",
"src/responses/"
]
},
Expand Down
8 changes: 8 additions & 0 deletions structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions tests/responses/chat-media-translation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>;
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 }] });
Expand Down
Loading