diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 8975c944cf2..7cec1af766f 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -81,6 +81,11 @@ With `stream: true`, the response is `text/event-stream`. The bridge emits Respo With `stream: false` or no `stream`, the same adapter events are collected into one Responses JSON object. Both forms preserve the selected model, output items, terminal status, and usage. +When a provider filters or truncates a response, an unfinished tool call remains `incomplete` +in both JSON and SSE. Partial output is preserved, and the bridge does not emit an argument +completion event for that open call. Calls already completed keep their status. This preserves +the provider outcome; client retry behavior for incomplete responses is unchanged. + On the pending `dev` implementation for #4112, a final upstream HTTP 413 on this surface is classified as `invalid_request_error` / `context_length_exceeded`. Non-streaming callers retain HTTP 413 with a JSON `error`; streaming callers retain the terminal SSE failure. diff --git a/src/bridge.ts b/src/bridge.ts index 20e7c3fe09f..3f5f529d3fa 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -1293,7 +1293,10 @@ export function bridgeToResponsesSSE( if (currentReasoning) closeCurrentReasoning(); if (currentRawReasoning) closeCurrentRawReasoning(); flushHiddenRawReasoning(); - if (currentToolCall) closeCurrentToolCall(); + if (currentToolCall) { + if (isTruncatedStopReason(event.stopReason)) failCurrentToolCall(); + else closeCurrentToolCall(); + } if (currentWebSearch) closeCurrentWebSearch("completed", []); releasePendingWebSources(); // Redacted-only turns (or hidden thinking without a trailing signature event) still @@ -2055,7 +2058,10 @@ function buildResponseJSONWithBudget( // must one left open by a stream that stopped without any terminal at all. That case previously // fell through to "completed", handing back a function_call whose arguments were half-written // JSON, inside a turn also marked completed. - if (currentToolCallId) flushToolCall(errorEvent || incompleteEvent || !sawTerminal ? "incomplete" : "completed"); + if (currentToolCallId) { + flushToolCall(errorEvent || incompleteEvent || !sawTerminal || isTruncatedStopReason(rawStopReason) + ? "incomplete" : "completed"); + } if (batchKiroRedacted) { // pushOutput reserves the item itself and releases the retained raw blob it replaces. pushOutput({ diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index b0fab66633e..86c630aacb4 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -57,6 +57,10 @@ so the schema is not something a user can fix from configuration (issue #2673). > Decision record: [ADR-0093](../decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md) +## Truncated tool finalization + +The bridge keeps an open function, custom, or tool-search call incomplete when an adapter ends with a recognized truncated stop reason. Streaming emits no argument/input completion frame for that open call, and buffered JSON applies the same status. A call already closed by its own tool-call end retains its completed state. The response remains incomplete, partial output is preserved, and truncated compaction never replaces history. + Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. diff --git a/tests/adapters/bridge-nonstreaming-terminal.test.ts b/tests/adapters/bridge-nonstreaming-terminal.test.ts index 7fe0a705abb..de6bfb37c90 100644 --- a/tests/adapters/bridge-nonstreaming-terminal.test.ts +++ b/tests/adapters/bridge-nonstreaming-terminal.test.ts @@ -310,3 +310,71 @@ describe("Command Code finishReason error is a failure, not a stop", () => { expect((json.output as { type: string }[]).some(o => o.type === "compaction")).toBe(false); }); }); + + +describe("truncated done preserves open tool integrity (#4312)", () => { + const cases = [ + ["refusal", "content_filter"], + ["content_filter", "content_filter"], + ["max_tokens", "max_output_tokens"], + ["length", "max_output_tokens"], + ] as const; + for (const [stopReason, reason] of cases) { + for (const kind of ["function_call", "custom_tool_call", "tool_search_call"] as const) { + test(`${stopReason}: open ${kind} stays incomplete in both projections`, async () => { + const options = { + freeformToolNames: kind === "custom_tool_call" ? new Set(["fixture"]) : undefined, + toolSearchToolNames: kind === "tool_search_call" ? new Set(["fixture"]) : undefined, + }; + const args = kind === "custom_tool_call" ? "partial input" : '{"arg":"unfinished'; + const events: AdapterEvent[] = [ + { type: "text_delta", text: "partial answer" }, + { type: "tool_call_start", id: "call_fixture", name: "fixture" }, + { type: "tool_call_delta", arguments: args }, + { type: "done", stopReason }, + ]; + async function* source(): AsyncGenerator { yield* events; } + const text = await new Response(bridgeToResponsesSSE( + source(), "fixture/model", undefined, options.freeformToolNames, options.toolSearchToolNames, + )).text(); + const frames = text.split("\n\n").flatMap(frame => { + const data = frame.split("\n").find(line => line.startsWith("data: "))?.slice(6); + return data && data !== "[DONE]" ? [JSON.parse(data)] : []; + }); + expect(terminalEventNames(text)).toEqual(["response.incomplete"]); + expect(frames.some(frame => frame.type === "response.function_call_arguments.done" + || frame.type === "response.custom_tool_call_input.done")).toBe(false); + const terminal = frames.find(frame => frame.type === "response.incomplete"); + const buffered = buildResponseJSON(events, "fixture/model", options); + for (const response of [terminal.response, buffered]) { + expect(response.status).toBe("incomplete"); + expect(response.incomplete_details.reason).toBe(reason); + const call = response.output.find((item: { type: string }) => item.type === kind); + expect(call.status).toBe("incomplete"); + if (kind === "function_call") expect(call.arguments).toBe(args); + if (kind === "custom_tool_call") expect(call.input).toBe(args); + expect(response.output.find((item: { type: string }) => item.type === "message") + .content[0].text).toBe("partial answer"); + } + const itemDone = frames.find(frame => frame.type === "response.output_item.done" && frame.item.type === kind); + expect(itemDone.item.status).toBe("incomplete"); + }); + } + test(`${stopReason}: a previously completed call stays completed`, async () => { + const events: AdapterEvent[] = [ + { type: "tool_call_start", id: "call_complete", name: "fixture" }, + { type: "tool_call_delta", arguments: '{"arg":"complete"}' }, + { type: "tool_call_end", id: "call_complete" }, + { type: "done", stopReason }, + ]; + const buffered = buildResponseJSON(events, "fixture/model"); + expect(buffered.status).toBe("incomplete"); + const output = buffered.output as { type: string; status?: string }[]; + expect(output.find(item => item.type === "function_call")?.status).toBe("completed"); + const text = await sseText(events); + expect(terminalEventNames(text)).toEqual(["response.incomplete"]); + expect(text).toContain("event: response.function_call_arguments.done"); + expect(text).toContain('"arguments":"{\\"arg\\":\\"complete\\"}","status":"completed"'); + }); + } +});