mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 17:12:27 +03:00
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:
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
203
tests/unit/translator-tool-result-json-passthrough.test.ts
Normal file
203
tests/unit/translator-tool-result-json-passthrough.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user