fix(sse): use Gemini schema for Antigravity Claude (#2063)

Integrated into release/v3.8.0
This commit is contained in:
ivan-mezentsev
2026-05-08 21:57:41 +03:00
committed by GitHub
parent 9875b40420
commit afdebbc793
5 changed files with 175 additions and 160 deletions

View File

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

View File

@@ -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<string, unknown>): 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);
}

View File

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

View File

@@ -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<T>(
name: string,
value: string | undefined,
fn: () => T | Promise<T>
): Promise<T> {
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);
});

View File

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