fix: pass tool result content through as string in OpenAI/Claude -> Gemini translation (#11624)

Merged via /merge-batch (lote 2026-08-26 batch 2, v3.8.51). Boarded no worktree combinado junto com outras ~20 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e 292 testes focados (unit) + 18 (vitest) passando. Obrigado pela contribuição.
This commit is contained in:
Hsia97
2026-08-26 20:21:56 +08:00
committed by GitHub
parent 86c03f1fed
commit 0481f61750
5 changed files with 209 additions and 20 deletions

View File

@@ -2,7 +2,6 @@ import { register } from "../registry.ts";
import { FORMATS } from "../formats.ts";
import {
DEFAULT_SAFETY_SETTINGS,
tryParseJSON,
cleanJSONSchemaForAntigravity,
} from "../helpers/geminiHelper.ts";
import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts";
@@ -186,13 +185,6 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) {
.map((c) => (c.type === "text" ? c.text : JSON.stringify(c)))
.join("\n");
}
let parsedContent = tryParseJSON(content);
if (parsedContent === null) {
parsedContent = { result: content };
} else if (typeof parsedContent !== "object") {
parsedContent = { result: parsedContent };
}
const toolUseId = block.tool_use_id;
const name = toolUseNames[toolUseId] || "unknown";
@@ -210,7 +202,7 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) {
functionResponse: {
...(stripFunctionCallId ? {} : { id: toolUseId }),
name,
response: { result: parsedContent },
response: { result: content },
},
});
break;

View File

@@ -511,18 +511,12 @@ function openaiToGeminiBase(
name = sanitizeToolName(name);
const resp = toolResponses[fid];
let parsedResp = tryParseJSON(resp);
if (parsedResp === null) {
parsedResp = { result: resp };
} else if (typeof parsedResp !== "object") {
parsedResp = { result: parsedResp };
}
toolParts.push({
functionResponse: {
...(toolNameOptions.stripFunctionCallId ? {} : { id: fid }),
name: name,
response: { result: parsedResp },
response: { result: resp },
},
});
}

View File

@@ -103,7 +103,7 @@ test("Claude -> Gemini maps system, thinking, tool use, tool result and tools",
functionResponse: {
id: "tu_1",
name: "weather",
response: { result: { result: "20C" } },
response: { result: "20C" },
},
});
assert.equal(result.generationConfig.maxOutputTokens, 256);

View File

@@ -289,7 +289,7 @@ test("OpenAI -> Gemini request maps messages, merged system instructions, tools
assert.deepEqual(getFunctionResponse(toolResponseTurn.parts[0]), {
id: "call_1",
name: "weather",
response: { result: { temp: 20 } },
response: { result: '{"temp":20}' },
});
const generationConfig = (result as GeminiRequestWithConfig).generationConfig;
@@ -550,7 +550,7 @@ test("OpenAI -> Cloud Code Gemini emits native functionResponse result", () => {
assert.deepEqual(getFunctionResponse(toolTurn.parts[0]), {
id: "read_file_123_0",
name: "read_file",
response: { result: { result: "The answer is capybara-4729." } },
response: { result: "The answer is capybara-4729." },
});
});
@@ -956,7 +956,7 @@ test("OpenAI -> Antigravity Claude path sanitizes tool names for Gemini schema",
const toolResultBlock = getFunctionResponse(toolTurn.parts[0]);
assert.equal(toolResultBlock.id, "call_long_2");
assert.equal(toolResultBlock.name, sanitizedToolName);
assert.deepEqual(toolResultBlock.response, { result: { ok: true } });
assert.deepEqual(toolResultBlock.response, { result: '{"ok":true}' });
});
test("OpenAI -> Antigravity Claude path applies output cap and strips thinkingConfig", () => {

View File

@@ -0,0 +1,203 @@
import test from "node:test";
import assert from "node:assert/strict";
const { openaiToGeminiRequest, openaiToAntigravityRequest } =
await import("../../open-sse/translator/request/openai-to-gemini.ts");
const { claudeToGeminiRequest } =
await import("../../open-sse/translator/request/claude-to-gemini.ts");
const {
buildGeminiThoughtSignatureKey,
storeGeminiThoughtSignature,
clearGeminiThoughtSignatures,
} = await import("../../open-sse/services/geminiThoughtSignatureStore.ts");
test.beforeEach(() => {
clearGeminiThoughtSignatures();
});
type UnknownRecord = Record<string, unknown>;
function getFunctionResponse(part: unknown) {
assert.ok(part && typeof part === "object", "expected Gemini part");
const functionResponse = (part as UnknownRecord).functionResponse;
assert.ok(functionResponse && typeof functionResponse === "object", "expected functionResponse");
return functionResponse as { id?: string; name: string; response?: unknown };
}
// A tool result whose payload is itself valid JSON (e.g. a WebFetch result
// `{"title": ..., "summary": ...}`). It must be passed through to Gemini /
// Antigravity as the raw string — it must NOT be JSON.parse'd into a nested
// object, which made Antigravity reject the request with HTTP 400 "upstream
// error" (upstream translator used tryParseJSON on tool result content).
const JSON_TOOL_RESULT = '{"title":"Example","summary":"antigravity 400 repro"}';
test("OpenAI -> Gemini keeps a JSON-string tool result as a raw string", () => {
const result = openaiToGeminiRequest(
"gemini-2.5-pro",
{
messages: [
{ role: "user", content: "fetch it" },
{
role: "assistant",
tool_calls: [
{
id: "call_json_1",
type: "function",
function: { name: "web_fetch", arguments: '{"url":"https://example.com"}' },
},
],
},
{ role: "tool", tool_call_id: "call_json_1", content: JSON_TOOL_RESULT },
],
},
false
);
const toolTurn = (result as { contents: Array<UnknownRecord> }).contents.find(
(c) => c.role === "user" && c.parts.some((part) => (part as UnknownRecord).functionResponse)
);
assert.ok(toolTurn, "expected a tool response turn");
assert.deepEqual(getFunctionResponse((toolTurn.parts as unknown[])[0]).response, {
result: JSON_TOOL_RESULT,
});
});
test("OpenAI -> Gemini keeps a plain-text tool result as a raw string (no double wrap)", () => {
const result = openaiToGeminiRequest(
"gemini-2.5-pro",
{
messages: [
{ role: "user", content: "read it" },
{
role: "assistant",
tool_calls: [
{
id: "call_txt_1",
type: "function",
function: { name: "read_file", arguments: '{"path":"a.txt"}' },
},
],
},
{ role: "tool", tool_call_id: "call_txt_1", content: "plain text output" },
],
},
false
);
const toolTurn = (result as { contents: Array<UnknownRecord> }).contents.find(
(c) => c.role === "user" && c.parts.some((part) => (part as UnknownRecord).functionResponse)
);
assert.ok(toolTurn, "expected a tool response turn");
assert.deepEqual(getFunctionResponse((toolTurn.parts as unknown[])[0]).response, {
result: "plain text output",
});
});
test("OpenAI -> Antigravity keeps a JSON-string tool result as a raw string", () => {
const result = openaiToAntigravityRequest(
"gemini-2.5-pro",
{
messages: [
{ role: "user", content: "fetch it" },
{
role: "assistant",
tool_calls: [
{
id: "call_json_2",
type: "function",
function: { name: "web_fetch", arguments: '{"url":"https://example.com"}' },
},
],
},
{ role: "tool", tool_call_id: "call_json_2", content: JSON_TOOL_RESULT },
],
},
false,
{ projectId: "proj-json-passthrough" } as never
);
const request = (result as unknown as { request: { contents: Array<UnknownRecord> } }).request;
const toolTurn = request.contents.find(
(c) => c.role === "user" && c.parts.some((part) => (part as UnknownRecord).functionResponse)
);
assert.ok(toolTurn, "expected an Antigravity tool response turn");
assert.deepEqual(getFunctionResponse((toolTurn.parts as unknown[])[0]).response, {
result: JSON_TOOL_RESULT,
});
});
test("Claude -> Gemini keeps a JSON-string tool_result as a raw string", () => {
// Native functionResponse requires a cached thoughtSignature for the tool use.
const ns = "conn-tool-result-json";
storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, "tu_json_1"), "SIG_JSON_1");
const result = claudeToGeminiRequest(
"gemini-2.5-pro",
{
messages: [
{ role: "user", content: [{ type: "text", text: "fetch it" }] },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "need tool" },
{
type: "tool_use",
id: "tu_json_1",
name: "web_fetch",
input: { url: "https://example.com" },
},
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "tu_json_1", content: JSON_TOOL_RESULT }],
},
],
},
false,
{ _signatureNamespace: ns } as never
);
const toolTurn = (result as { contents: Array<UnknownRecord> }).contents.find(
(c) => c.role === "user" && c.parts.some((part) => (part as UnknownRecord).functionResponse)
);
assert.ok(toolTurn, "expected a tool response turn");
assert.deepEqual(getFunctionResponse((toolTurn.parts as unknown[])[0]).response, {
result: JSON_TOOL_RESULT,
});
});
test("Claude -> Gemini keeps a plain-text tool_result as a raw string (no double wrap)", () => {
const ns = "conn-tool-result-txt";
storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, "tu_txt_1"), "SIG_TXT_1");
const result = claudeToGeminiRequest(
"gemini-2.5-pro",
{
messages: [
{ role: "user", content: [{ type: "text", text: "read it" }] },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "need tool" },
{ type: "tool_use", id: "tu_txt_1", name: "read_file", input: { path: "a.txt" } },
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "tu_txt_1", content: "plain text output" }],
},
],
},
false,
{ _signatureNamespace: ns } as never
);
const toolTurn = (result as { contents: Array<UnknownRecord> }).contents.find(
(c) => c.role === "user" && c.parts.some((part) => (part as UnknownRecord).functionResponse)
);
assert.ok(toolTurn, "expected a tool response turn");
assert.deepEqual(getFunctionResponse((toolTurn.parts as unknown[])[0]).response, {
result: "plain text output",
});
});