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
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down
4 changes: 4 additions & 0 deletions structure/adapters/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions tests/adapters/bridge-nonstreaming-terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AdapterEvent> { 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"');
});
}
});
Loading