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
102 changes: 98 additions & 4 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,15 @@ function toolOutputText(output: unknown): string {
* expansion misses (proxy restart, unrecorded prior turn), previous_response_id is stripped
* (the ChatGPT backend rejects it), so the delta may carry items that reference now-absent
* prior items and 400 upstream:
* - `function_call`/`local_shell_call`/`custom_tool_call` without their paired output item
* ("No tool output found for tool call <call_id>"). A stateless upstream cannot resolve
* the pair from its own storage, so a placeholder output is synthesized to keep the
* turn continuable without pretending the result was real. Synthetic outputs are
* emitted after the complete parallel call batch, in call order alongside any real
* outputs, so the adjacency normalizer can still recognize the batch as one
* reasoning-bearing assistant turn (#1477). Gated on
* `synthesizeMissingCallOutputs` (stateless AND non-forward wires); forward replay keeps
* fail-closed behavior.
* - `function_call_output`/`custom_tool_call_output` without their paired call item
* ("No tool call found for function call output with call_id ..."). Converted to user
* messages so the result text survives. `function_call_output` also pairs with
Expand Down Expand Up @@ -608,26 +617,38 @@ function backfillWebSearchQueries(body: unknown): unknown {
return changed ? { ...body, input } : body;
}

function repairOrphanedInputItems(body: unknown, dropReasoning: boolean): unknown {
function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthesizeMissingCallOutputs = false): unknown {
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
const input = body.input;

const functionCallIds = new Set<string>();
const customCallIds = new Set<string>();
const functionOutputIds = new Set<string>();
const customOutputIds = new Set<string>();
for (const item of input) {
if (!isPlainObject(item) || typeof item.call_id !== "string") continue;
if (item.type === "function_call" || item.type === "local_shell_call") functionCallIds.add(item.call_id);
else if (item.type === "custom_tool_call") customCallIds.add(item.call_id);
else if (item.type === "function_call_output") functionOutputIds.add(item.call_id);
else if (item.type === "custom_tool_call_output") customOutputIds.add(item.call_id);
}

let changed = false;
const repaired: unknown[] = [];
const syntheticKeys = new Set<string>();
const pendingSyntheticOutputs: unknown[] = [];
const flushPendingSyntheticOutputs = (): void => {
if (pendingSyntheticOutputs.length === 0) return;
repaired.push(...pendingSyntheticOutputs);
pendingSyntheticOutputs.length = 0;
};
for (const item of input) {
if (!isPlainObject(item)) { repaired.push(item); continue; }
if (!isPlainObject(item)) { flushPendingSyntheticOutputs(); repaired.push(item); continue; }
if (dropReasoning && item.type === "reasoning") { changed = true; continue; }
const isFnOutput = item.type === "function_call_output";
const isCustomOutput = item.type === "custom_tool_call_output";
if (isFnOutput || isCustomOutput) {
flushPendingSyntheticOutputs();
const callId = typeof item.call_id === "string" ? item.call_id : "";
const paired = isFnOutput ? functionCallIds.has(callId) : customCallIds.has(callId);
if (!paired) {
Expand All @@ -640,10 +661,83 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean): unknow
continue;
}
}
const isFnCall = item.type === "function_call" || item.type === "local_shell_call";
const isCustomCall = item.type === "custom_tool_call";
if (isFnCall || isCustomCall) {
repaired.push(item);
if (synthesizeMissingCallOutputs) {
const callId = typeof item.call_id === "string" ? item.call_id : "";
const hasOutput = isFnCall ? functionOutputIds.has(callId) : customOutputIds.has(callId);
if (!hasOutput && callId) {
changed = true;
const name = typeof item.name === "string" && item.name.length > 0 ? item.name : callId;
const text = `[ocx] no tool result was recorded for "${name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`;
syntheticKeys.add(`${isFnCall ? "function" : "custom"}:${callId}`);
pendingSyntheticOutputs.push(isFnCall
? { type: "function_call_output", call_id: callId, output: text }
: { type: "custom_tool_call_output", call_id: callId, output: text });
}
}
continue;
}
flushPendingSyntheticOutputs();
repaired.push(item);
}
flushPendingSyntheticOutputs();

const callKeyOf = (item: unknown): string | null => {
if (!isPlainObject(item) || typeof item.call_id !== "string") return null;
if (item.type === "function_call" || item.type === "local_shell_call") return `function:${item.call_id}`;
if (item.type === "custom_tool_call") return `custom:${item.call_id}`;
return null;
};
const outputKeyOf = (item: unknown): string | null => {
if (!isPlainObject(item) || typeof item.call_id !== "string") return null;
if (item.type === "function_call_output") return `function:${item.call_id}`;
if (item.type === "custom_tool_call_output") return `custom:${item.call_id}`;
return null;
};
const reorderBatchOutputs = (items: unknown[]): unknown[] => {
const ordered: unknown[] = [];
let index = 0;
while (index < items.length) {
const key = callKeyOf(items[index]);
if (key === null) { ordered.push(items[index]); index += 1; continue; }
const batch: unknown[] = [];
const batchKeys: string[] = [];
let cursor = index;
while (cursor < items.length) {
const nextKey = callKeyOf(items[cursor]);
if (nextKey === null) break;
batch.push(items[cursor]);
batchKeys.push(nextKey);
cursor += 1;
}
const hasSynthetic = batchKeys.some(batchKey => syntheticKeys.has(batchKey));
if (!hasSynthetic) {
ordered.push(...batch);
index = cursor;
continue;
}
const remainder: unknown[] = [];
const batchOutputs: Array<{ key: string; item: unknown }> = [];
for (let probe = cursor; probe < items.length; probe += 1) {
const outputKey = outputKeyOf(items[probe]);
if (outputKey !== null && batchKeys.includes(outputKey)) {
batchOutputs.push({ key: outputKey, item: items[probe] });
} else {
remainder.push(items[probe]);
}
}
batchOutputs.sort((left, right) => batchKeys.indexOf(left.key) - batchKeys.indexOf(right.key));
ordered.push(...batch, ...batchOutputs.map(output => output.item));
ordered.push(...reorderBatchOutputs(remainder));
return ordered;
}
return ordered;
};

return changed ? { ...body, input: repaired } : body;
return changed ? { ...body, input: reorderBatchOutputs(repaired) } : body;
}

/**
Expand Down Expand Up @@ -1375,7 +1469,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
// backend gets — dropping previous_response_id is not much use if the body that
// reaches the wire is unparseable.
if (forward || stateless) {
outBody = repairOrphanedInputItems(outBody, unexpandedMiss);
outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward);
}
if (provider.requiresAdjacentResponsesToolResults === true) {
outBody = normalizeResponsesToolResultAdjacency(outBody);
Expand Down
15 changes: 13 additions & 2 deletions tests/deepseek-inbound-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,15 +906,26 @@ describe("stateless Responses upstreams get no stateful parameters", () => {
expect(body.input).toEqual(input);
});

test("DeepSeek fails closed when a collected call has no matching result", () => {
test("DeepSeek synthesizes a placeholder result when a collected call has no matching result", () => {
const callA = { type: "function_call", call_id: "call_a", name: "read_file", arguments: "{}" };
const callB = { type: "function_call", call_id: "call_b", name: "read_file", arguments: "{}" };
const outputB = { type: "function_call_output", call_id: "call_b", output: "B" };
const injected = { type: "message", role: "developer", content: [{ type: "input_text", text: "context" }] };
const input = [callA, callB, injected, outputB];

const body = buildBody(deepseekProvider(), { input }) as { input: unknown[] };
expect(body.input).toEqual(input);
const repaired = body.input as Array<Record<string, unknown>>;
// The parallel call batch stays contiguous: the synthetic output for call_a is
// emitted after call_b, and the injected context moves after the whole batch.
expect(repaired[0]).toMatchObject({ type: "function_call", call_id: "call_a" });
expect(repaired[1]).toMatchObject({ type: "function_call", call_id: "call_b" });
const synthesized = repaired[2] as Record<string, unknown>;
expect(synthesized.type).toBe("function_call_output");
expect(synthesized.call_id).toBe("call_a");
expect(String(synthesized.output)).toContain("no tool result was recorded");
// The real result for call_b survives untouched.
expect(repaired[3]).toMatchObject({ type: "function_call_output", call_id: "call_b", output: "B" });
expect(repaired[4]).toMatchObject({ type: "message", role: "developer" });
});

test("DeepSeek fails closed when a collected call/result pair is backwards", () => {
Expand Down
74 changes: 74 additions & 0 deletions tests/responses-forward-dangling-call.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Forward-mode replay keeps the prior fail-closed behavior for orphaned tool CALLS.
*
* The stateless-wire repair (tests/responses-stateless-dangling-call-repair.test.ts)
* synthesizes placeholder outputs only when statelessResponses is true. A forward-auth
* provider (ChatGPT backend replay) must NOT synthesize: dangling calls stay exactly as
* the client sent them so the strict upstream decides, mirroring the pre-fix contract.
*/
import { describe, expect, test } from "bun:test";
import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../src/adapters/openai-responses";
import { createTranslatorBudget } from "../src/lib/translator-budget";
import { withTestTranslatorBudget } from "./helpers/translator-budget";

const createResponsesPassthroughAdapter = (...args: Parameters<typeof createResponsesPassthroughAdapterProduction>) =>
withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args));

const provider = {
adapter: "openai-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
authMode: "forward" as const,
};

function buildInput(input: unknown[]): unknown[] {
const adapter = createResponsesPassthroughAdapter(provider);
const request = adapter.buildRequest({
modelId: "gpt-5.5",
context: { messages: [] },
stream: true,
options: {},
_rawBody: { model: "gpt-5.5", input },
}, { headers: new Headers({ authorization: "Bearer caller-secret" }) });
return (JSON.parse(request.body) as { input: unknown[] }).input;
}

describe("forward-mode replay keeps fail-closed behavior (no synthesized outputs)", () => {
test("a dangling function_call is forwarded unchanged on forward-mode replay", () => {
const input = [
{ type: "function_call", id: "fc_fwd", call_id: "call_fwd", name: "write_stdin", arguments: "{}" },
];
const built = buildInput(input);
expect(built).toEqual(input);
});

test("a dangling custom_tool_call is forwarded unchanged on forward-mode replay", () => {
const input = [
{ type: "custom_tool_call", id: "ctc_fwd", call_id: "call_ct_fwd", name: "custom_probe", input: "{}" },
];
const built = buildInput(input);
expect(built).toEqual(input);
});

test("forward auth with statelessResponses still does not synthesize (fail-closed guard)", () => {
const adapter = createResponsesPassthroughAdapter({
...provider,
statelessResponses: true,
});
const input = [
{ type: "function_call", id: "fc_fwd_stateless", call_id: "call_fwd_stateless", name: "write_stdin", arguments: "{}" },
];
const request = adapter.buildRequest({
modelId: "gpt-5.5",
context: { messages: [] },
stream: true,
options: {},
_rawBody: { model: "gpt-5.5", input },
}, { headers: new Headers({ authorization: "Bearer caller-secret" }) });
const built = (JSON.parse(request.body) as { input: unknown[] }).input;
// Stateless upstreams strip item ids, but the guard must not synthesize an output.
expect(built).toHaveLength(1);
expect(built[0]).toMatchObject({ type: "function_call", call_id: "call_fwd_stateless" });
expect(JSON.stringify(request.body)).not.toContain("no tool result was recorded");
});
});

Loading
Loading