Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 48 additions & 12 deletions open-sse/executors/antigravity.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import crypto from "crypto";
import { BaseExecutor } from "./base.js";
import { readBoundedResponseText } from "../utils/error.js";
import { errorResponse, readBoundedResponseText } from "../utils/error.js";
import { PROVIDERS } from "../config/providers.js";
import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX } from "../config/appConstants.js";
import { dbg } from "../utils/debugLog.js";
Expand Down Expand Up @@ -105,6 +105,28 @@ function compressToolSchema(schema, depth) {
return schema;
}

/**
* Repair only adjacency created by Antigravity's thought filter (#3366).
* Translator normalization owns broader conversation policy; native passthrough must not fabricate turns.
*/
function normalizeFilteredContents(contents) {
const normalized = [];
for (const content of contents || []) {
if (!content?.role || !Array.isArray(content.parts) || content.parts.length === 0) continue;
const previous = normalized.at(-1);
if (previous?.role === content.role) previous.parts.push(...content.parts);
else normalized.push({ ...content, parts: [...content.parts] });
}
return normalized;
}

function filterThoughtParts(parts) {
return parts?.filter(part => {
if (part.thought && !part.functionCall) return false;
if (part.thoughtSignature && !part.functionCall && !part.text) return false;
return true;
});
}
// Image generation model name patterns
const IMAGE_MODEL_PATTERNS = [
/image/i,
Expand Down Expand Up @@ -164,7 +186,21 @@ export class AntigravityExecutor extends BaseExecutor {
constructor(provider = "antigravity") {
super(provider, PROVIDERS[provider] || PROVIDERS.antigravity);
}

async execute(options) {
const filteredContents = options.body?.request?.contents?.map(content => ({
...content,
parts: filterThoughtParts(content?.parts),
}));
if (!isImageModel(options.model) && normalizeFilteredContents(filteredContents).length === 0) {
return {
response: errorResponse(HTTP_STATUS.BAD_REQUEST, "Antigravity request has no contents after thought filtering"),
url: "",
headers: {},
transformedBody: null,
};
}
return super.execute(options);
}
buildUrl(model, stream, urlIndex = 0) {
const baseUrls = this.getBaseUrls();
const baseUrl = baseUrls[urlIndex] || baseUrls[0];
Expand Down Expand Up @@ -240,18 +276,18 @@ export class AntigravityExecutor extends BaseExecutor {

// ─── Standard (non-image) request ───
// Fix contents for Claude models via Antigravity
const contents = body.request?.contents?.map(c => {
/**
* Normalize only turns emptied by thought filtering; Antigravity rejects empty parts and adjacent roles.
* Ported from upstream decolua/9router#3366 without importing translator continuation policy.
*/
const contents = normalizeFilteredContents(body.request?.contents?.map(c => {
let role = c.role;
// functionResponse must be role "user" for Claude models
if (c.parts?.some(p => p.functionResponse)) {
role = "user";
}
// Strip thought-only parts, keep thoughtSignature on functionCall parts (Gemini 3+ requires it)
const parts = c.parts?.filter(p => {
if (p.thought && !p.functionCall) return false;
if (p.thoughtSignature && !p.functionCall && !p.text) return false;
return true;
});
const parts = filterThoughtParts(c.parts);
// Gemini 3+ rejects functionCall parts without thoughtSignature. Clients (Claude Code, IDE)
// don't persist thoughtSignature in their history, so backfill the default signature on any
// functionCall part that arrives without one.
Expand All @@ -267,7 +303,7 @@ export class AntigravityExecutor extends BaseExecutor {
};
}
return c;
});
}));

// Sanitize tool schemas and function names before sending to Antigravity.
let tools = body.request?.tools;
Expand Down Expand Up @@ -326,8 +362,8 @@ export class AntigravityExecutor extends BaseExecutor {
tools = allDeclarations.length > 0 ? [{ functionDeclarations: allDeclarations }] : [];
}

// Strip tools/toolConfig (handled separately) and blacklisted fields that Google rejects
const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {};
// Strip contents/tools/toolConfig (handled separately) and blacklisted fields that Google rejects
const { contents: _originalContents, tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {};
stripBlacklisted(requestWithoutTools);

// Rewrite competitive system prompts (e.g. Zed IDE's Claude SDK marker) to prevent
Expand All @@ -350,7 +386,7 @@ export class AntigravityExecutor extends BaseExecutor {
const transformedRequest = {
...requestWithoutTools,
generationConfig,
...(contents && { contents }),
...(contents.length > 0 && { contents }),
...(tools && { tools }),
sessionId: body.request?.sessionId || resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.email || credentials?.connectionId, scope: "antigravity" }),
safetySettings: undefined,
Expand Down
92 changes: 91 additions & 1 deletion tests/unit/executors/antigravity-payload-limits.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { AntigravityExecutor } from "../../../open-sse/executors/antigravity.js";
import { BaseExecutor } from "../../../open-sse/executors/base.js";

const credentials = { projectId: "synthetic-project", connectionId: "synthetic-connection" };

Expand Down Expand Up @@ -48,6 +49,95 @@ describe("Antigravity payload limits", () => {
});
});

it("renormalizes native contents after dropping a thought-only model turn", () => {
const request = transform({
contents: [
{ role: "user", parts: [{ text: "before" }] },
{ role: "model", parts: [{ text: "private reasoning", thought: true }] },
{ role: "user", parts: [{ text: "after" }] },
],
});
const roles = request.contents.map(content => content.role);

expect(roles.some((role, index) => role === roles[index - 1])).toBe(false);
expect(request.contents).toEqual([
{ role: "user", parts: [{ text: "before" }, { text: "after" }] },
]);
});

it("keeps native terminal model turns without fabricating continuation content", () => {
const terminalText = transform({
contents: [
{ role: "user", parts: [{ text: "question" }] },
{ role: "model", parts: [{ text: "answer" }] },
],
});
const terminalCall = transform({
contents: [
{ role: "user", parts: [{ text: "question" }] },
{ role: "model", parts: [{ functionCall: { name: "lookup", args: {} } }] },
],
});

expect(terminalText.contents.at(-1)).toEqual({ role: "model", parts: [{ text: "answer" }] });
expect(terminalText.contents.flatMap(content => content.parts)).not.toContainEqual({ text: "Continue" });
expect(terminalCall.contents.at(-1)).toEqual({
role: "model",
parts: [expect.objectContaining({ functionCall: { name: "lookup", args: {} } })],
});
expect(terminalCall.contents.flatMap(content => content.parts).some(part => part.functionResponse)).toBe(false);
});

it("keeps native content parts in order", () => {
const request = transform({
contents: [
{ role: "model", parts: [
{ functionCall: { name: "lookup", args: { query: "answer" } } },
{ inlineData: { mimeType: "image/png", data: "inline" } },
] },
{ role: "model", parts: [
{ functionResponse: { name: "lookup", response: { result: "found" } } },
{ fileData: { mimeType: "text/plain", fileUri: "gs://bucket/file" } },
{ text: "answer" },
] },
{ role: "user", parts: [{ text: "after" }] },
],
});

expect(request.contents.flatMap(content => content.parts.map(part =>
part.functionCall?.name
?? part.inlineData?.data
?? part.functionResponse?.name
?? part.fileData?.fileUri
?? part.text
))).toEqual(["lookup", "inline", "lookup", "gs://bucket/file", "answer", "after"]);
});
it("rejects native contents emptied by thought filtering", async () => {
const executor = new AntigravityExecutor();
const dispatch = vi.spyOn(BaseExecutor.prototype, "execute");
try {
const result = await executor.execute({
model: "gemini-2.5-pro",
body: { request: { contents: [
{ role: "model", parts: [{ text: "private reasoning", thought: true }] },
] } },
stream: false,
credentials,
});

expect(result.response.status).toBe(400);
expect(await result.response.json()).toEqual({
error: expect.objectContaining({
message: "Antigravity request has no contents after thought filtering",
type: "invalid_request_error",
}),
});
expect(dispatch).not.toHaveBeenCalled();
} finally {
dispatch.mockRestore();
}
});

it("embeds an oversized system instruction into first user message", () => {
const systemText = "s".repeat(4001);
const request = transform({
Expand Down
Loading