fix(gemini): re-attach thoughtSignature (#2504) + normalize PDF content parts (#2515)

#2504: thread _signatureNamespace through the FORMATS.GEMINI and FORMATS.GEMINI_CLI
request translators so a cached Gemini thoughtSignature is re-attached to the
functionCall on the follow-up turn (was 400 'missing thought_signature').
#2515: accept input_file (Responses API) on the Gemini path and document (Gemini-style)
on the Responses/Codex path so PDFs reach the model regardless of content-part name.
This commit is contained in:
diegosouzapw
2026-05-21 20:30:40 -03:00
parent 50d549ac4e
commit 83ba78cbcd
4 changed files with 99 additions and 13 deletions

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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 `<connectionId>:<toolCallId>`) 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<string, unknown>)._signatureNamespace === "string"
? ((credentials as Record<string, unknown>)._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<string, unknown>)._signatureNamespace === "string"
? ((credentials as Record<string, unknown>)._signatureNamespace as string)
: null,
}),
credentials
),
null

View File

@@ -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"
);
});