diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index d9ec1fb01a..a0aed2c608 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -906,6 +906,61 @@ function toolOutputText(output: unknown): string { }).filter(Boolean).join("\n"); } +/** True when an output can be losslessly represented as user-message content. */ +function isRepairableToolOutput(output: unknown): output is string | Record[] { + if (typeof output === "string") return true; + if (!Array.isArray(output)) return false; + return output.every(part => { + if (!isPlainObject(part)) return false; + if (typeof part.type !== "string") return false; + if (["output_text", "text", "input_text"].includes(part.type)) { + return typeof part.text === "string"; + } + if (part.type === "refusal") return typeof part.refusal === "string"; + if (part.type === "encrypted_content") return typeof part.encrypted_content === "string"; + if (part.type !== "input_image") return false; + const imageUrl = part.image_url; + const fileId = part.file_id; + const imageUrlIsString = typeof imageUrl === "string"; + const fileIdIsString = typeof fileId === "string"; + const hasUsableSource = (imageUrlIsString && imageUrl.length > 0) + || (fileIdIsString && fileId.length > 0); + const validSource = hasUsableSource + && (part.image_url === undefined || imageUrlIsString) + && (part.file_id === undefined || fileIdIsString); + const validDetail = part.detail === undefined + || (typeof part.detail === "string" + && ["auto", "low", "high", "original"].includes(part.detail)); + return validSource && validDetail; + }); +} + +/** Convert orphaned tool output to user-message content without discarding valid images. */ +function orphanedToolOutputContent(output: unknown, callId = ""): Record[] { + const marker = `[tool output for ${callId || "unknown call"}]`; + if (typeof output !== "string" && !Array.isArray(output)) { + return [{ type: "input_text", text: marker }]; + } + if (!Array.isArray(output)) { + return [{ type: "input_text", text: `${marker}\n${toolOutputText(output)}` }]; + } + + const content: Record[] = [{ type: "input_text", text: marker }]; + for (const part of output) { + if (!isPlainObject(part)) continue; + if (part.type === "input_image") { + content.push(part); + } else if (part.type === "encrypted_content" && typeof part.encrypted_content === "string") { + content.push({ type: "input_text", text: "[encrypted content omitted]" }); + } else if (typeof part.text === "string") { + content.push({ type: "input_text", text: part.text }); + } else if (part.type === "refusal" && typeof part.refusal === "string") { + content.push({ type: "input_text", text: `[refusal] ${part.refusal}` }); + } + } + return content; +} + /** True when a Responses tool output item is present but carries no usable content. */ function isToolOutputEmpty(output: unknown): boolean { if (typeof output === "string") return output.trim() === ""; @@ -940,6 +995,32 @@ function annotateEmptyResponsesToolOutputs(body: unknown, enabled: boolean): unk return changed ? { ...body, input } : body; } +/** + * Preserve the text of structurally invalid tool-output items before they reach a strict + * Responses parser. Stateful destinations may legitimately receive an output whose matching + * call lives behind `previous_response_id`, so ordinary orphan repair cannot run universally. + * A missing or empty `call_id`, however, cannot identify stored state on any destination. + */ +function repairUnidentifiedToolOutputItems(body: unknown): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + let changed = false; + const input = body.input.map(item => { + if (!isPlainObject(item) + || (item.type !== "function_call_output" && item.type !== "custom_tool_call_output") + || (typeof item.call_id === "string" && item.call_id.length > 0)) { + return item; + } + if (!isRepairableToolOutput(item.output)) return item; + changed = true; + return { + type: "message", + role: "user", + content: orphanedToolOutputContent(item.output), + }; + }); + return changed ? { ...body, input } : body; +} + /** * Repair a forward-mode input array whose continuation context was lost. When the replay * expansion misses (proxy restart, unrecorded prior turn), previous_response_id is stripped @@ -1060,12 +1141,17 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthes flushPendingSyntheticOutputs(); const callId = typeof item.call_id === "string" ? item.call_id : ""; const paired = isFnOutput ? functionCallIds.has(callId) : customCallIds.has(callId); - if (!paired) { + const usableOutput = isRepairableToolOutput(item.output); + // A known orphan call is still useful as a labeled user message even when its output is + // incomplete. With no call id and no output, preserve the invalid item so validation fails + // closed rather than pretending any tool result exists. + const knownNullOutput = callId.length > 0 && item.output == null; + if (!paired && (knownNullOutput || usableOutput)) { changed = true; repaired.push({ type: "message", role: "user", - content: [{ type: "input_text", text: `[tool output for ${callId || "unknown call"}]\n${toolOutputText(item.output)}` }], + content: orphanedToolOutputContent(item.output, callId), }); continue; } @@ -2272,11 +2358,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Same predicate as the routedCompaction gate in handleResponses(): an authMode check would // let a noncanonical custom forward provider skip this rewrite while the server still routes // it as a summarizer turn (#422). The compaction body build removes the tool surface and must - // therefore be the last routed transform: anything before it may depend on the declarations; - // anything after it cannot. + // therefore be the last routed transform that may depend on those declarations. Structural + // sanitizers below can still run after it. if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) { outBody = buildRoutedCompactionBody(outBody); } + // Run after routed compaction so nested input_image parts are replaced before a malformed + // tool output is flattened to text and can no longer be inspected structurally. + outBody = repairUnidentifiedToolOutputItems(outBody); const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true; const sanitizedBody = normalizeToolSchemas( stripSparkCompatibility( diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 2048ed92e5..d411614626 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -2245,8 +2245,137 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(rawDeltaBody.input).toHaveLength(1); }); + test("api-key mode preserves delegated tool output text when call_id is missing", () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key" as const, + apiKey: "xai-test", + }); + + const body = JSON.parse(adapter.buildRequest({ + ...parsedBase, + previousResponseId: undefined, + _rawBody: { + model: "grok-4.6", + input: [ + { + type: "function_call_output", + id: "fco_delegation", + output: "Inspect the adapter.", + }, + ], + }, + }, meta).body) as { input: Record[] }; + + expect(body.input).toEqual([{ + type: "message", + role: "user", + content: [{ + type: "input_text", + text: "[tool output for unknown call]\nInspect the adapter.", + }], + }]); + }); + + test("api-key mode keeps stateful tool outputs with call_id intact", () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key" as const, + apiKey: "xai-test", + }); + const statefulOutput = { + type: "function_call_output", + call_id: "call_from_previous_response", + output: "tool result", + }; + + const body = JSON.parse(adapter.buildRequest({ + ...parsedBase, + _rawBody: { + model: "grok-4.6", + previous_response_id: "resp_stateful", + input: [statefulOutput], + }, + }, meta).body) as { previous_response_id?: string; input: Record[] }; + + expect(body.previous_response_id).toBe("resp_stateful"); + expect(body.input).toEqual([statefulOutput]); + }); + + test("api-key mode preserves images when repairing output without call_id", () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key" as const, + apiKey: "xai-test", + }); + const image = { + type: "input_image", + image_url: "data:image/png;base64,AAAA", + detail: "high", + }; + + const body = JSON.parse(adapter.buildRequest({ + ...parsedBase, + _rawBody: { + model: "grok-4.6", + input: [{ + type: "function_call_output", + output: [ + { type: "input_text", text: "screenshot" }, + image, + { type: "encrypted_content", encrypted_content: "opaque-tool-state" }, + ], + }], + }, + }, meta).body) as { input: Array<{ content: Record[] }> }; + + expect(body.input[0]?.content).toEqual([ + { type: "input_text", text: "[tool output for unknown call]" }, + { type: "input_text", text: "screenshot" }, + image, + { type: "input_text", text: "[encrypted content omitted]" }, + ]); + }); + + test("api-key mode leaves invalid output without call_id fail-closed", () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key" as const, + apiKey: "xai-test", + }); + const invalidOutputs = [ + { type: "custom_tool_call_output" }, + { type: "function_call_output", output: [{ type: "bogus", value: "not a tool-output part" }] }, + { + type: "function_call_output", + output: [{ type: "input_image", image_url: "data:image/png;base64,AAAA", detail: ["high"] }], + }, + { + type: "function_call_output", + output: [{ type: "input_image", image_url: 42, file_id: "file_1" }], + }, + { type: "function_call_output", output: [{ type: "input_image", image_url: "" }] }, + { + type: "function_call_output", + output: [{ type: { toString: null, valueOf: null }, text: "must not coerce" }], + }, + ]; + + const body = JSON.parse(adapter.buildRequest({ + ...parsedBase, + _rawBody: { model: "grok-4.6", input: invalidOutputs }, + }, meta).body) as { input: Record[] }; + + expect(body.input).toEqual(invalidOutputs); + }); + test("forward unexpanded miss converts orphan tool outputs and drops reasoning", () => { const adapter = createResponsesPassthroughAdapter(provider); + const image = { type: "input_image", image_url: "data:image/png;base64,AAAA" }; const body = JSON.parse(adapter.buildRequest({ ...parsedBase, _rawBody: { @@ -2255,7 +2384,11 @@ describe("OpenAI Responses passthrough sanitization", () => { input: [ { type: "reasoning", id: "rs_1", summary: [] }, { type: "function_call_output", call_id: "call_orphan", output: "tool said hi" }, - { type: "custom_tool_call_output", call_id: "call_custom", output: [{ type: "output_text", text: "custom out" }] }, + { + type: "custom_tool_call_output", + call_id: "call_custom", + output: [{ type: "output_text", text: "custom out" }, image], + }, { role: "user", content: "next question" }, ], }, @@ -2272,7 +2405,11 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.input[1]).toMatchObject({ type: "message", role: "user", - content: [{ type: "input_text", text: "[tool output for call_custom]\ncustom out" }], + content: [ + { type: "input_text", text: "[tool output for call_custom]" }, + { type: "input_text", text: "custom out" }, + image, + ], }); expect(body.input[2]).toMatchObject({ role: "user", content: "next question" }); });