diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 0ee9591150..beff4fb525 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -128,16 +128,14 @@ export function convertOpenAIContentToParts(content: unknown): JsonRecord[] { continue; } - // 3. Handle raw data strings (e.g. {"type": "file", "data": "JVBER...", "mime_type": "..."}) + // 3. Handle raw data strings (e.g. {"type": "file", "data": "JVBER...", "mime_type": "..."}). + // Also accept the Responses-API shape {"type":"input_file","file_data":"JVBER...","filename":...} + // so PDFs sent as `input_file` reach Gemini instead of being silently dropped (#2515). const file = toRecord(rec.file); const doc = toRecord(rec.document); - const rawDataStr = rec.data || file?.data || doc?.data; + const rawDataStr = rec.data || rec.file_data || file?.data || doc?.data; const mimeTypeFallback = - rec.mime_type || - rec.media_type || - file?.mime_type || - doc?.mime_type || - "application/octet-stream"; + rec.mime_type || rec.media_type || file?.mime_type || doc?.mime_type || "application/pdf"; if (typeof rawDataStr === "string" && !rawDataStr.startsWith("http")) { const rawData = rawDataStr.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""); parts.push({ @@ -154,7 +152,13 @@ export function convertOpenAIContentToParts(content: unknown): JsonRecord[] { const fileUrl = toRecord(rec.file_url); const fileObj = toRecord(rec.file); const docObj = toRecord(rec.document); - const fileData = imageUrl?.url || fileUrl?.url || fileObj?.url || docObj?.url; + // `file_url` is a top-level string on the Responses-API input_file shape (#2515). + const fileData = + (typeof rec.file_url === "string" ? rec.file_url : undefined) || + imageUrl?.url || + fileUrl?.url || + fileObj?.url || + docObj?.url; if (typeof fileData === "string" && fileData.startsWith("data:")) { const commaIndex = fileData.indexOf(","); if (commaIndex !== -1) { diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 6d72f1f5f0..1e906499a5 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -377,13 +377,21 @@ export function openaiToOpenAIResponsesRequest( } return imgResult; } - if (contentItem.type === "file") { - const file = toRecord(contentItem.file); + if (contentItem.type === "file" || contentItem.type === "document") { + // Accept both the OpenAI `file` shape and the Gemini-style `document` shape, + // and map the bare `data`/`url` fields too, so a PDF reaches Codex/Responses + // regardless of which content-part name the client used (#2515). + const file = toRecord( + contentItem.type === "document" ? contentItem.document : contentItem.file + ); const fileResult: JsonRecord = { type: "input_file" }; if (file.file_data !== undefined) fileResult.file_data = file.file_data; + else if (file.data !== undefined) fileResult.file_data = file.data; if (file.file_id !== undefined) fileResult.file_id = file.file_id; if (file.file_url !== undefined) fileResult.file_url = file.file_url; + else if (file.url !== undefined) fileResult.file_url = file.url; if (file.filename !== undefined) fileResult.filename = file.filename; + else if (file.name !== undefined) fileResult.filename = file.name; return fileResult; } return contentValue; diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 2140278d3f..6370a787db 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -456,8 +456,18 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName } // OpenAI -> Gemini (standard API) -export function openaiToGeminiRequest(model, body, stream) { - return openaiToGeminiBase(model, body, stream); +export function openaiToGeminiRequest(model, body, stream, credentials = null) { + // Thread the signature namespace so a thinking model's thoughtSignature (cached on the + // response turn under `:`) is found and re-attached to the + // functionCall on the follow-up request. Without this the streaming lookup key didn't + // match and Gemini rejected tool calls with 400 "missing thought_signature" (#2504). + const signatureNamespace = + credentials && + typeof credentials === "object" && + typeof (credentials as Record)._signatureNamespace === "string" + ? ((credentials as Record)._signatureNamespace as string) + : null; + return openaiToGeminiBase(model, body, stream, { signatureNamespace }); } // OpenAI -> Gemini CLI (Cloud Code Assist) @@ -642,7 +652,16 @@ register( (model, body, stream, credentials) => wrapInCloudCodeEnvelope( model, - openaiToGeminiCLIRequest(model, body, stream, { functionResponseShape: "output" }), + openaiToGeminiCLIRequest(model, body, stream, { + functionResponseShape: "output", + // Forward the signature namespace so streaming thoughtSignatures round-trip (#2504). + signatureNamespace: + credentials && + typeof credentials === "object" && + typeof (credentials as Record)._signatureNamespace === "string" + ? ((credentials as Record)._signatureNamespace as string) + : null, + }), credentials ), null diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index daf2b12e61..e75651f1af 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -891,3 +891,58 @@ test("OpenAI -> Antigravity Gemini path preserves thinkingConfig (only Claude is assert.equal((result as any).request?.generationConfig.thinkingConfig.thinkingBudget > 0, true); assert.equal((result as any).request?.generationConfig.thinkingConfig.includeThoughts, true); }); + +// Regression for #2515: a PDF sent in the Responses-API `input_file` shape must reach +// Gemini as inlineData instead of being silently dropped. +test("convertOpenAIContentToParts handles input_file file_data (#2515)", () => { + const parts = convertOpenAIContentToParts([ + { type: "input_file", file_data: "JVBERi0xLjcKJ", filename: "doc.pdf" }, + ]); + const inline = parts.find((p) => (p as any).inlineData); + assert.ok(inline, "input_file with file_data must produce an inlineData part"); + assert.equal((inline as any).inlineData.data, "JVBERi0xLjcKJ"); +}); + +test("convertOpenAIContentToParts handles input_file file_url data URI (#2515)", () => { + const parts = convertOpenAIContentToParts([ + { type: "input_file", file_url: "data:application/pdf;base64,QUJD", filename: "d.pdf" }, + ]); + const inline = parts.find((p) => (p as any).inlineData); + assert.ok(inline, "input_file with file_url data URI must produce an inlineData part"); + assert.equal((inline as any).inlineData.data, "QUJD"); + assert.equal((inline as any).inlineData.mimeType, "application/pdf"); +}); + +// Regression for #2504: with credentials._signatureNamespace set, a previously-cached +// Gemini thoughtSignature must be re-attached to the functionCall on the follow-up turn. +test("openaiToGeminiRequest re-attaches cached thoughtSignature for FORMATS.GEMINI (#2504)", async () => { + const { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature } = + await import("../../open-sse/services/geminiThoughtSignatureStore.ts"); + const ns = "conn-2504"; + const toolId = "call_2504_abc"; + storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId), "SIG_2504_XYZ"); + + const result: any = openaiToGeminiRequest( + "gemini-2.5-pro-preview", + { + messages: [ + { role: "user", content: "run a tool" }, + { + role: "assistant", + tool_calls: [ + { id: toolId, type: "function", function: { name: "Bash", arguments: '{"cmd":"ls"}' } }, + ], + }, + { role: "tool", tool_call_id: toolId, content: "ok" }, + ], + }, + false, + { _signatureNamespace: ns } + ); + + const json = JSON.stringify(result); + assert.ok( + json.includes("SIG_2504_XYZ"), + "cached thoughtSignature must be re-attached to the functionCall" + ); +});