From afdebbc793cce519afc676fee73f2bff4da71fad Mon Sep 17 00:00:00 2001 From: ivan-mezentsev Date: Fri, 8 May 2026 21:57:41 +0300 Subject: [PATCH] fix(sse): use Gemini schema for Antigravity Claude (#2063) Integrated into release/v3.8.0 --- open-sse/executors/antigravity.ts | 112 +++++++++--------- .../translator/request/openai-to-gemini.ts | 52 +------- tests/unit/antigravity-model-aliases.test.ts | 31 +++-- tests/unit/executor-antigravity.test.ts | 57 ++++++--- .../unit/translator-openai-to-gemini.test.ts | 83 ++++++++----- 5 files changed, 175 insertions(+), 160 deletions(-) diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 0b6982d8c3..88bd8254c5 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -306,66 +306,64 @@ export class AntigravityExecutor extends BaseExecutor { ? stripCloudCodeThinkingConfig(baseBody) : baseBody; - let transformedRequest; + // Fix contents for Gemini-compatible Cloud Code requests via Antigravity. + // Claude-branded Antigravity models use the same streamGenerateContent schema. + const normalizedContents = + normalizedBody.request?.contents?.map((c) => { + let role = c.role; + if (c.parts?.some((p) => p.functionResponse)) { + role = "user"; + } + + const hasFunctionCall = c.parts?.some((p) => p.functionCall) || false; + + const parts = + c.parts?.filter((p) => { + if (typeof p.text === "string" && p.text === "") return false; + if (p.functionCall && !p.functionCall.name) return false; + + return !p.thought && (hasFunctionCall || !p.thoughtSignature); + }) || []; + return { ...c, role, parts }; + }) || []; + + const contents = []; + for (const c of normalizedContents) { + if (!Array.isArray(c.parts) || c.parts.length === 0) continue; + if (contents.length > 0 && contents[contents.length - 1].role === c.role) { + contents[contents.length - 1].parts.push(...c.parts); + } else { + contents.push(c); + } + } + + const transformedRequest = { + ...normalizedBody.request, + ...(contents.length > 0 && { contents }), + sessionId: normalizedBody.request?.sessionId || this.generateSessionId(), + safetySettings: undefined, + toolConfig: + normalizedBody.request?.tools?.length > 0 + ? { functionCallingConfig: { mode: "VALIDATED" } } + : normalizedBody.request?.toolConfig, + }; if (isClaude) { - // Claude models on Vertex AI Cloud Code expect the native Anthropic payload - // exactly as generated by openaiToClaudeRequestForAntigravity, without Gemini mappings. - transformedRequest = { - ...normalizedBody.request, - sessionId: normalizedBody.request?.sessionId || this.generateSessionId(), - }; - } else { - // Fix contents for Gemini models via Antigravity - const normalizedContents = - normalizedBody.request?.contents?.map((c) => { - let role = c.role; - if (c.parts?.some((p) => p.functionResponse)) { - role = "user"; - } + delete transformedRequest.messages; + delete transformedRequest.system; + delete transformedRequest.max_tokens; + delete transformedRequest.stream; + delete transformedRequest.temperature; + } - const hasFunctionCall = c.parts?.some((p) => p.functionCall) || false; - - const parts = - c.parts?.filter((p) => { - if (typeof p.text === "string" && p.text === "") return false; - if (p.functionCall && !p.functionCall.name) return false; - - return !p.thought && (hasFunctionCall || !p.thoughtSignature); - }) || []; - return { ...c, role, parts }; - }) || []; - - const contents = []; - for (const c of normalizedContents) { - if (!Array.isArray(c.parts) || c.parts.length === 0) continue; - if (contents.length > 0 && contents[contents.length - 1].role === c.role) { - contents[contents.length - 1].parts.push(...c.parts); - } else { - contents.push(c); - } - } - - transformedRequest = { - ...normalizedBody.request, - ...(contents.length > 0 && { contents }), - sessionId: normalizedBody.request?.sessionId || this.generateSessionId(), - safetySettings: undefined, - toolConfig: - normalizedBody.request?.tools?.length > 0 - ? { functionCallingConfig: { mode: "VALIDATED" } } - : normalizedBody.request?.toolConfig, - }; - - // Obfuscate sensitive client names in user content (e.g. "OpenCode", "Cursor") - const requestContents = transformedRequest.contents; - if (Array.isArray(requestContents)) { - for (const msg of requestContents) { - if (Array.isArray(msg.parts)) { - for (const part of msg.parts) { - if (typeof part.text === "string") { - part.text = obfuscateSensitiveWords(part.text); - } + // Obfuscate sensitive client names in user content (e.g. "OpenCode", "Cursor") + const requestContents = transformedRequest.contents; + if (Array.isArray(requestContents)) { + for (const msg of requestContents) { + if (Array.isArray(msg.parts)) { + for (const part of msg.parts) { + if (typeof part.text === "string") { + part.text = obfuscateSensitiveWords(part.text); } } } diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 018c6fdb5a..65839b52fd 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -3,7 +3,6 @@ import { FORMATS } from "../formats.ts"; import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/constants.ts"; import { resolveGeminiThoughtSignature } from "../../services/geminiThoughtSignatureStore.ts"; -import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.ts"; import { capMaxOutputTokens, capThinkingBudget, @@ -481,62 +480,15 @@ function getAntigravityClaudeOutputTokens(body: Record): number return ANTIGRAVITY_CLAUDE_MAX_OUTPUT_TOKENS; } -function wrapInCloudCodeEnvelopeForClaude( - model, - claudeRequest, - credentials = null, - sourceBody = {} -) { - let projectId = credentials?.projectId; - - if (!projectId) { - console.warn( - `[OmniRoute] Antigravity/Claude account is missing projectId. ` + - `Attempting request with empty project — reconnect OAuth to resolve.` - ); - projectId = ""; - } - - const cleanModel = model.includes("/") ? model.split("/").pop()! : model; - - // Keep Antigravity's default and caller-provided system rules - let systemText = ANTIGRAVITY_DEFAULT_SYSTEM; - if (claudeRequest.system) { - if (Array.isArray(claudeRequest.system)) { - const texts = claudeRequest.system.map((b) => b.text).filter(Boolean); - if (texts.length > 0) systemText += "\n" + texts.join("\n"); - } else if (typeof claudeRequest.system === "string") { - systemText += "\n" + claudeRequest.system; - } - } - - const envelope: CloudCodeEnvelope = { - project: projectId, - model: cleanModel, - userAgent: "antigravity", - requestId: `agent-${generateUUID()}`, - requestType: "agent", - request: { - ...claudeRequest, - system: systemText, - max_tokens: getAntigravityClaudeOutputTokens(sourceBody), - sessionId: generateSessionId(), - }, - }; - - return envelope; -} - // OpenAI -> Antigravity (Sandbox Cloud Code with wrapper) export function openaiToAntigravityRequest(model, body, stream, credentials = null) { const isClaude = model.toLowerCase().includes("claude"); + const geminiCLI = openaiToGeminiCLIRequest(model, body, stream); if (isClaude) { - const claudeRequest = openaiToClaudeRequestForAntigravity(model, body, stream); - return wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials, body); + geminiCLI.generationConfig.maxOutputTokens = getAntigravityClaudeOutputTokens(body); } - const geminiCLI = openaiToGeminiCLIRequest(model, body, stream); return wrapInCloudCodeEnvelope(model, geminiCLI, credentials, true); } diff --git a/tests/unit/antigravity-model-aliases.test.ts b/tests/unit/antigravity-model-aliases.test.ts index 44a20fcbea..57ce1a6c9d 100644 --- a/tests/unit/antigravity-model-aliases.test.ts +++ b/tests/unit/antigravity-model-aliases.test.ts @@ -56,26 +56,43 @@ test("AntigravityExecutor.transformRequest resolves alias models before dispatch { projectId: "project-1" } ); + if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); assert.equal(result.model, "gemini-3.1-pro-high"); }); -test("AntigravityExecutor.transformRequest keeps Claude bridge output cap and strips unsupported thinking", async () => { +test("AntigravityExecutor.transformRequest sends Claude through Gemini-compatible Cloud Code schema", async () => { const executor = new AntigravityExecutor(); const bridged = openaiToAntigravityRequest( - "claude-sonnet-4-6", + "claude-opus-4-6-thinking", { messages: [{ role: "user", content: "Hello" }], max_completion_tokens: 32_000, + temperature: 0.5, reasoning_effort: "high", }, true, { projectId: "project-1" } as any ); - const result = await executor.transformRequest("antigravity/claude-sonnet-4-6", bridged, true, { - projectId: "project-1", - }); + const result = await executor.transformRequest( + "antigravity/claude-opus-4-6-thinking", + bridged, + true, + { + projectId: "project-1", + } + ); - assert.equal(result.request.max_tokens, 16_384); - assert.equal(result.request.thinking, undefined); + if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); + const request = result.request as any; + assert.deepEqual(request.contents, [{ role: "user", parts: [{ text: "Hello" }] }]); + assert.equal(request.generationConfig.maxOutputTokens, 16_384); + assert.equal(request.generationConfig.temperature, 0.5); + assert.equal(request.messages, undefined); + assert.equal(request.system, undefined); + assert.equal(request.max_tokens, undefined); + assert.equal(request.stream, undefined); + assert.equal(request.temperature, undefined); + assert.equal(request.thinking, undefined); + assert.equal(request.generationConfig.thinkingConfig, undefined); }); diff --git a/tests/unit/executor-antigravity.test.ts b/tests/unit/executor-antigravity.test.ts index 993ca818af..f41e84b14c 100644 --- a/tests/unit/executor-antigravity.test.ts +++ b/tests/unit/executor-antigravity.test.ts @@ -1,4 +1,4 @@ -import test from "node:test"; +import { test } from "node:test"; import assert from "node:assert/strict"; import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts"; @@ -9,7 +9,11 @@ import { seedAntigravityVersionCache, } from "../../open-sse/services/antigravityVersion.ts"; -async function withEnv(name, value, fn) { +async function withEnv( + name: string, + value: string | undefined, + fn: () => T | Promise +): Promise { const previous = process.env[name]; if (value === undefined) { delete process.env[name]; @@ -94,6 +98,7 @@ test("AntigravityExecutor.transformRequest normalizes model, project and content projectId: "project-1", }); + if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); assert.equal(result.project, "project-1"); assert.equal(result.model, "gemini-3.1-pro-low"); assert.deepEqual(Object.keys(result), [ @@ -132,8 +137,12 @@ test("AntigravityExecutor.transformRequest strips thinking config for Cloud Code projectId: "project-1", }); + if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); + const generationConfig = result.request.generationConfig as { + thinkingConfig?: { thinkingBudget?: number; includeThoughts?: boolean }; + }; assert.equal(result.reasoning_effort, undefined); - assert.equal(result.request.generationConfig.thinkingConfig, undefined); + assert.equal(generationConfig.thinkingConfig, undefined); }); test("AntigravityExecutor.transformRequest preserves thinking config for supported Gemini models", async () => { @@ -154,8 +163,12 @@ test("AntigravityExecutor.transformRequest preserves thinking config for support projectId: "project-1", }); - assert.equal(result.request.generationConfig.thinkingConfig.thinkingBudget, 8192); - assert.equal(result.request.generationConfig.thinkingConfig.includeThoughts, true); + if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); + const generationConfig = result.request.generationConfig as { + thinkingConfig: { thinkingBudget?: number; includeThoughts?: boolean }; + }; + assert.equal(generationConfig.thinkingConfig.thinkingBudget, 8192); + assert.equal(generationConfig.thinkingConfig.includeThoughts, true); }); test("AntigravityExecutor.transformRequest tolerates a missing body when projectId is present", async () => { @@ -165,6 +178,7 @@ test("AntigravityExecutor.transformRequest tolerates a missing body when project projectId: "project-1", }); + if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); assert.equal(result.project, "project-1"); assert.equal(result.model, "gemini-3.1-pro-low"); assert.ok(result.request.sessionId); @@ -202,6 +216,7 @@ test("AntigravityExecutor.transformRequest allows body project overrides when th { projectId: "credential-project" } ); + if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); assert.equal(result.project, "body-project"); assert.equal(result.request.sessionId, "session-fixed"); assert.equal(result.model, "gemini-2.5-pro"); @@ -393,7 +408,7 @@ test("AntigravityExecutor.execute auto-retries short 429 responses and collects ); }; globalThis.setTimeout = ((callback) => { - callback(); + (callback as () => void)(); return 0; }) as typeof setTimeout; @@ -504,7 +519,7 @@ test("AntigravityExecutor.execute applies CLI fingerprint when enabled", async ( } }); -test("AntigravityExecutor.transformRequest bypasses Gemini contents mapping for claude models", async () => { +test("AntigravityExecutor.transformRequest maps Claude models through Gemini contents schema", async () => { const executor = new AntigravityExecutor(); const body = { project: "project-1", @@ -513,12 +528,17 @@ test("AntigravityExecutor.transformRequest bypasses Gemini contents mapping for requestId: "agent-123", requestType: "agent", request: { - messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], - system: [{ type: "text", text: "System prompt" }], + contents: [{ role: "user", parts: [{ text: "Hello" }] }], + systemInstruction: { role: "system", parts: [{ text: "System prompt" }] }, generationConfig: { temperature: 1, maxOutputTokens: 16384, }, + messages: [{ role: "user", content: [{ type: "text", text: "Legacy Anthropic field" }] }], + system: [{ type: "text", text: "Legacy system field" }], + max_tokens: 16384, + stream: true, + temperature: 1, }, }; @@ -530,10 +550,19 @@ test("AntigravityExecutor.transformRequest bypasses Gemini contents mapping for assert.equal(result.model, "claude-sonnet-4-6"); assert.equal(result.requestType, "agent"); assert.ok(result.request.sessionId); - assert.deepEqual(result.request.messages, [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ]); - assert.deepEqual(result.request.system, [{ type: "text", text: "System prompt" }]); - assert.equal(result.request.contents, undefined); + assert.deepEqual(result.request.contents, [{ role: "user", parts: [{ text: "Hello" }] }]); + assert.deepEqual(result.request.systemInstruction, { + role: "system", + parts: [{ text: "System prompt" }], + }); + assert.deepEqual(result.request.generationConfig, { + temperature: 1, + maxOutputTokens: 16384, + }); + assert.equal(result.request.messages, undefined); + assert.equal(result.request.system, undefined); + assert.equal(result.request.max_tokens, undefined); + assert.equal(result.request.stream, undefined); + assert.equal(result.request.temperature, undefined); assert.equal(result.request.toolConfig, undefined); }); diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index afac00bd68..3db195480d 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -616,7 +616,7 @@ test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", () }); }); -test("OpenAI -> Antigravity uses the Claude bridge for Claude-family models", () => { +test("OpenAI -> Antigravity maps Claude-family models to Gemini-compatible schema", () => { const result = openaiToAntigravityRequest( "claude-3-7-sonnet", { @@ -659,28 +659,32 @@ test("OpenAI -> Antigravity uses the Claude bridge for Claude-family models", () assert.equal(result.project, "proj-claude"); assert.equal(result.userAgent, "antigravity"); - assert.ok((result as any).request?.system.includes(ANTIGRAVITY_DEFAULT_SYSTEM)); - assert.ok((result as any).request?.system.includes("Project rules")); - assert.equal((result as any).request?.max_tokens, 16384); + assert.equal(result.request.systemInstruction.parts[0].text, ANTIGRAVITY_DEFAULT_SYSTEM); + assert.equal(result.request.systemInstruction.parts[1].text, "Project rules"); + assert.equal((result as any).request?.generationConfig.maxOutputTokens, 16384); + assert.equal((result as any).request?.messages, undefined); + assert.equal((result as any).request?.system, undefined); + assert.equal((result as any).request?.max_tokens, undefined); + assert.equal((result as any).request?.stream, undefined); - const modelTurn = result.request.messages.find( - (msg) => msg.role === "assistant" && msg.content.some((block) => block.type === "tool_use") + const modelTurn = result.request.contents.find( + (content) => content.role === "model" && content.parts.some((part) => part.functionCall) ); - assert.ok(modelTurn, "expected a Claude-bridged model turn"); - const bridgeFunctionCall = modelTurn.content.find((block) => block.type === "tool_use"); + assert.ok(modelTurn, "expected a Gemini-compatible model turn"); + const bridgeFunctionCall = getFunctionCall(modelTurn.parts[0]); assert.equal(bridgeFunctionCall.name, "read_file"); - assert.deepEqual(bridgeFunctionCall.input, { path: "/tmp/demo" }); + assert.deepEqual(bridgeFunctionCall.args, { path: "/tmp/demo" }); - const toolTurn = result.request.messages.find( - (msg) => msg.role === "user" && msg.content.some((block) => block.type === "tool_result") + const toolTurn = result.request.contents.find( + (content) => content.role === "user" && content.parts.some((part) => part.functionResponse) ); - assert.ok(toolTurn, "expected a Claude-bridged tool response turn"); - const toolResultBlock = toolTurn.content.find((block) => block.type === "tool_result"); - assert.equal(toolResultBlock.tool_use_id, "call_1"); - assert.equal((result as any).request?.tools[0].name, "read_file"); + assert.ok(toolTurn, "expected a Gemini-compatible tool response turn"); + const toolResultBlock = getFunctionResponse(toolTurn.parts[0]); + assert.equal(toolResultBlock.id, "call_1"); + assert.equal((result as any).request?.tools[0].functionDeclarations[0].name, "read_file"); }); -test("OpenAI -> Antigravity Claude bridge preserves tool names (Claude supports longer names)", () => { +test("OpenAI -> Antigravity Claude path sanitizes tool names for Gemini schema", () => { const longToolName = "ns:mcp__filesystem__read_multiple_files_with_validation_and_metadata_bundle"; const result = openaiToAntigravityRequest( @@ -722,26 +726,31 @@ test("OpenAI -> Antigravity Claude bridge preserves tool names (Claude supports { projectId: "proj-claude-map" } as any ); - const sanitizedToolName = (result as any).request?.tools[0].name; - assert.equal(sanitizedToolName, longToolName); + const sanitizedToolName = (result as any).request?.tools[0].functionDeclarations[0].name; + assert.notEqual(sanitizedToolName, longToolName); + assert.match( + sanitizedToolName, + /^mcp_filesystem_read_multiple_files_with_validation_and__\w{8}$/ + ); - const modelTurn = result.request.messages.find( - (msg) => msg.role === "assistant" && msg.content.some((block) => block.type === "tool_use") + const modelTurn = result.request.contents.find( + (content) => content.role === "model" && content.parts.some((part) => part.functionCall) ); assert.ok(modelTurn, "expected a model turn"); - const toolUseBlock = modelTurn.content.find((block) => block.type === "tool_use"); + const toolUseBlock = getFunctionCall(modelTurn.parts[0]); assert.equal(toolUseBlock.name, sanitizedToolName); - const toolTurn = result.request.messages.find( - (msg) => msg.role === "user" && msg.content.some((block) => block.type === "tool_result") + const toolTurn = result.request.contents.find( + (content) => content.role === "user" && content.parts.some((part) => part.functionResponse) ); assert.ok(toolTurn, "expected a tool response turn"); - const toolResultBlock = toolTurn.content.find((block) => block.type === "tool_result"); - assert.equal(toolResultBlock.tool_use_id, "call_long_2"); - assert.ok(toolResultBlock.content.includes("ok")); + 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 } }); }); -test("OpenAI -> Antigravity Claude bridge applies Antigravity output cap but forwards thinking", () => { +test("OpenAI -> Antigravity Claude path applies output cap in generationConfig", () => { const result = openaiToAntigravityRequest( "claude-3-7-sonnet", { @@ -753,11 +762,16 @@ test("OpenAI -> Antigravity Claude bridge applies Antigravity output cap but for { projectId: "proj-claude-thinking" } as any ); - assert.equal((result as any).request?.max_tokens, 16384); - assert.deepEqual((result as any).request?.thinking, { type: "enabled", budget_tokens: 131072 }); + assert.equal((result as any).request?.generationConfig.maxOutputTokens, 16384); + assert.deepEqual((result as any).request?.generationConfig.thinkingConfig, { + thinkingBudget: 32768, + includeThoughts: true, + }); + assert.equal((result as any).request?.max_tokens, undefined); + assert.equal((result as any).request?.thinking, undefined); }); -test("OpenAI -> Antigravity Claude bridge preserves lower requested output despite reasoning effort", () => { +test("OpenAI -> Antigravity Claude path preserves lower requested output", () => { const result = openaiToAntigravityRequest( "claude-3-7-sonnet", { @@ -769,6 +783,11 @@ test("OpenAI -> Antigravity Claude bridge preserves lower requested output despi { projectId: "proj-claude-short" } as any ); - assert.equal((result as any).request?.max_tokens, 1000); - assert.deepEqual((result as any).request?.thinking, { type: "enabled", budget_tokens: 131072 }); + assert.equal((result as any).request?.generationConfig.maxOutputTokens, 1000); + assert.deepEqual((result as any).request?.generationConfig.thinkingConfig, { + thinkingBudget: 32768, + includeThoughts: true, + }); + assert.equal((result as any).request?.max_tokens, undefined); + assert.equal((result as any).request?.thinking, undefined); });