Skip to content

Commit 3f9c14e

Browse files
committed
fix(request): copy thoughtSignature to all functionCall parts in the same turn to satisfy gemini-3.1-pro requirements
1 parent efafbb9 commit 3f9c14e

2 files changed

Lines changed: 289 additions & 4 deletions

File tree

src/plugin/request.test.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from "vitest";
22
import {
33
prepareAntigravityRequest,
4+
transformAntigravityResponse,
45
getPluginSessionId,
56
isGenerativeLanguageRequest,
67
__testExports,
@@ -517,6 +518,31 @@ describe("request.ts", () => {
517518
});
518519

519520
describe("prepareAntigravityRequest", () => {
521+
it("copies thoughtSignature to all functionCall parts in the same turn", () => {
522+
const mockPayload = {
523+
contents: [
524+
{
525+
role: "model",
526+
parts: [
527+
{ functionCall: { name: "foo", args: {} }, thoughtSignature: "signature-123" },
528+
{ functionCall: { name: "bar", args: {} } }
529+
]
530+
}
531+
]
532+
};
533+
534+
const result = prepareAntigravityRequest(
535+
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro:generateContent",
536+
{ method: "POST", body: JSON.stringify(mockPayload) },
537+
"test-token",
538+
"test-project"
539+
);
540+
541+
const parsedBody = JSON.parse(result.init.body as string);
542+
expect(parsedBody.request.contents[0].parts[0].thoughtSignature).toBe("signature-123");
543+
expect(parsedBody.request.contents[0].parts[1].thoughtSignature).toBe("signature-123");
544+
});
545+
520546
const mockAccessToken = "test-token";
521547
const mockProjectId = "test-project";
522548

@@ -731,6 +757,61 @@ it("removes x-api-key header", () => {
731757
expect(result.streaming).toBe(false);
732758
});
733759

760+
it("removes contents entries with empty or invalid parts", () => {
761+
const result = prepareAntigravityRequest(
762+
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
763+
{
764+
method: "POST",
765+
body: JSON.stringify({
766+
contents: [
767+
{ role: "user", parts: [] },
768+
{ role: "model", parts: [null, { text: "kept" }] },
769+
{ role: "user", parts: null },
770+
],
771+
systemInstruction: {
772+
role: "user",
773+
parts: [null, { text: "system kept" }],
774+
},
775+
}),
776+
},
777+
mockAccessToken,
778+
mockProjectId,
779+
undefined,
780+
"gemini-cli",
781+
);
782+
783+
const wrapped = JSON.parse(result.init.body as string);
784+
expect(wrapped.request.contents).toHaveLength(1);
785+
expect(wrapped.request.contents[0]).toEqual({
786+
role: "model",
787+
parts: [{ text: "kept" }],
788+
});
789+
expect(wrapped.request.systemInstruction.parts).toEqual([{ text: "system kept" }]);
790+
});
791+
792+
it("drops systemInstruction when all parts are invalid", () => {
793+
const result = prepareAntigravityRequest(
794+
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
795+
{
796+
method: "POST",
797+
body: JSON.stringify({
798+
contents: [{ role: "user", parts: [{ text: "hi" }] }],
799+
systemInstruction: {
800+
role: "user",
801+
parts: [null],
802+
},
803+
}),
804+
},
805+
mockAccessToken,
806+
mockProjectId,
807+
undefined,
808+
"gemini-cli",
809+
);
810+
811+
const wrapped = JSON.parse(result.init.body as string);
812+
expect(wrapped.request.systemInstruction).toBeUndefined();
813+
});
814+
734815
it("preserves headerStyle in response", () => {
735816
const result = prepareAntigravityRequest(
736817
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent",
@@ -768,6 +849,30 @@ it("removes x-api-key header", () => {
768849
expect(result.effectiveModel).toBe("gemini-3-pro-low");
769850
});
770851

852+
it("transforms gemini-3.1-pro-preview to gemini-3.1-pro-low for antigravity headerStyle", () => {
853+
const result = prepareAntigravityRequest(
854+
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-preview:generateContent",
855+
{ method: "POST", body: JSON.stringify({ contents: [] }) },
856+
mockAccessToken,
857+
mockProjectId,
858+
undefined,
859+
"antigravity"
860+
);
861+
expect(result.effectiveModel).toBe("gemini-3.1-pro-low");
862+
});
863+
864+
it("transforms gemini-3.1-pro-preview-customtools to gemini-3.1-pro-low for antigravity headerStyle", () => {
865+
const result = prepareAntigravityRequest(
866+
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-preview-customtools:generateContent",
867+
{ method: "POST", body: JSON.stringify({ contents: [] }) },
868+
mockAccessToken,
869+
mockProjectId,
870+
undefined,
871+
"antigravity"
872+
);
873+
expect(result.effectiveModel).toBe("gemini-3.1-pro-low");
874+
});
875+
771876
it("transforms gemini-3-flash to gemini-3-flash-preview for gemini-cli headerStyle", () => {
772877
const result = prepareAntigravityRequest(
773878
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:generateContent",
@@ -792,6 +897,30 @@ it("removes x-api-key header", () => {
792897
expect(result.effectiveModel).toBe("gemini-3-pro-preview");
793898
});
794899

900+
it("transforms gemini-3.1-pro-low to gemini-3.1-pro-preview for gemini-cli headerStyle", () => {
901+
const result = prepareAntigravityRequest(
902+
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-low:generateContent",
903+
{ method: "POST", body: JSON.stringify({ contents: [] }) },
904+
mockAccessToken,
905+
mockProjectId,
906+
undefined,
907+
"gemini-cli"
908+
);
909+
expect(result.effectiveModel).toBe("gemini-3.1-pro-preview");
910+
});
911+
912+
it("keeps gemini-3.1-pro-preview-customtools unchanged for gemini-cli headerStyle", () => {
913+
const result = prepareAntigravityRequest(
914+
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-preview-customtools:generateContent",
915+
{ method: "POST", body: JSON.stringify({ contents: [] }) },
916+
mockAccessToken,
917+
mockProjectId,
918+
undefined,
919+
"gemini-cli"
920+
);
921+
expect(result.effectiveModel).toBe("gemini-3.1-pro-preview-customtools");
922+
});
923+
795924
it("keeps non-Gemini-3 models unchanged regardless of headerStyle", () => {
796925
const result = prepareAntigravityRequest(
797926
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
@@ -805,4 +934,66 @@ it("removes x-api-key header", () => {
805934
});
806935
});
807936
});
937+
938+
describe("transformAntigravityResponse", () => {
939+
it("does not misclassify generic INVALID_ARGUMENT as thinking recovery from debug metadata", async () => {
940+
const response = new Response(
941+
JSON.stringify({
942+
error: {
943+
code: 400,
944+
message: "Request contains an invalid argument.",
945+
status: "INVALID_ARGUMENT",
946+
},
947+
}),
948+
{
949+
status: 400,
950+
headers: { "content-type": "application/json" },
951+
},
952+
);
953+
954+
const transformed = await transformAntigravityResponse(
955+
response,
956+
true,
957+
undefined,
958+
"antigravity-claude-opus-4-6-thinking",
959+
"test-project",
960+
"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse",
961+
"claude-opus-4-6-thinking",
962+
"session-1",
963+
0,
964+
"expected=1 found=0",
965+
);
966+
967+
await expect(transformed.text()).resolves.toContain("Request contains an invalid argument.");
968+
});
969+
970+
it("rethrows THINKING_RECOVERY_NEEDED for outer retry handling", async () => {
971+
const response = new Response(
972+
JSON.stringify({
973+
error: {
974+
code: 400,
975+
message: "Thinking must start with a thinking block before tool use.",
976+
status: "INVALID_ARGUMENT",
977+
},
978+
}),
979+
{
980+
status: 400,
981+
headers: { "content-type": "application/json" },
982+
},
983+
);
984+
985+
await expect(
986+
transformAntigravityResponse(
987+
response,
988+
true,
989+
undefined,
990+
"antigravity-claude-opus-4-6-thinking",
991+
"test-project",
992+
"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse",
993+
"claude-opus-4-6-thinking",
994+
"session-1",
995+
),
996+
).rejects.toMatchObject({ message: "THINKING_RECOVERY_NEEDED" });
997+
});
998+
});
808999
});

src/plugin/request.ts

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { defaultSignatureStore } from "./stores/signature-store";
2020
import {
2121
DEBUG_MESSAGE_PREFIX,
2222
isDebugEnabled,
23+
isDebugTuiEnabled,
2324
logAntigravityDebugResponse,
2425
logCacheStats,
2526
type AntigravityDebugContext,
@@ -337,6 +338,65 @@ function stripInjectedDebugFromRequestPayload(payload: Record<string, unknown>):
337338
}
338339
}
339340

341+
function isValidRequestPart(part: unknown): boolean {
342+
if (!part || typeof part !== "object") {
343+
return false;
344+
}
345+
346+
const record = part as Record<string, unknown>;
347+
348+
return (
349+
Object.prototype.hasOwnProperty.call(record, "text") ||
350+
Object.prototype.hasOwnProperty.call(record, "functionCall") ||
351+
Object.prototype.hasOwnProperty.call(record, "functionResponse") ||
352+
Object.prototype.hasOwnProperty.call(record, "inlineData") ||
353+
Object.prototype.hasOwnProperty.call(record, "fileData") ||
354+
Object.prototype.hasOwnProperty.call(record, "executableCode") ||
355+
Object.prototype.hasOwnProperty.call(record, "codeExecutionResult") ||
356+
Object.prototype.hasOwnProperty.call(record, "thought")
357+
);
358+
}
359+
360+
function sanitizeRequestPayloadForAntigravity(payload: Record<string, unknown>): void {
361+
const anyPayload = payload as any;
362+
363+
if (Array.isArray(anyPayload.contents)) {
364+
anyPayload.contents = anyPayload.contents
365+
.map((content: unknown) => {
366+
if (!content || typeof content !== "object") {
367+
return null;
368+
}
369+
370+
const contentRecord = content as Record<string, unknown>;
371+
const rawParts = Array.isArray(contentRecord.parts) ? contentRecord.parts : [];
372+
const sanitizedParts = rawParts.filter(isValidRequestPart);
373+
374+
if (sanitizedParts.length === 0) {
375+
return null;
376+
}
377+
378+
return {
379+
...contentRecord,
380+
parts: sanitizedParts,
381+
};
382+
})
383+
.filter((content: unknown): content is Record<string, unknown> => content !== null);
384+
}
385+
386+
const systemInstruction = anyPayload.systemInstruction;
387+
if (systemInstruction && typeof systemInstruction === "object" && !Array.isArray(systemInstruction)) {
388+
const sys = systemInstruction as Record<string, unknown>;
389+
if (Array.isArray(sys.parts)) {
390+
const sanitizedSystemParts = sys.parts.filter(isValidRequestPart);
391+
if (sanitizedSystemParts.length > 0) {
392+
sys.parts = sanitizedSystemParts;
393+
} else {
394+
delete anyPayload.systemInstruction;
395+
}
396+
}
397+
}
398+
}
399+
340400
function isGeminiToolUsePart(part: any): boolean {
341401
return !!(part && typeof part === "object" && (part.functionCall || part.tool_use || part.toolUse));
342402
}
@@ -1177,6 +1237,29 @@ export function prepareAntigravityRequest(
11771237
const conversationKey = resolveConversationKey(requestPayload);
11781238
signatureSessionKey = buildSignatureSessionKey(PLUGIN_SESSION_ID, effectiveModel, conversationKey, resolveProjectKey(projectId));
11791239

1240+
// Ensure thoughtSignature is present on all functionCall parts if at least one part has it
1241+
// This is required by Gemini 3.1 Pro which otherwise fails with 400 Bad Request
1242+
if (Array.isArray(requestPayload.contents)) {
1243+
requestPayload.contents = requestPayload.contents.map((content: any) => {
1244+
if (!content || !Array.isArray(content.parts)) return content;
1245+
1246+
// Find if any part has a thoughtSignature
1247+
const signature = content.parts.find((p: any) => p && typeof p === "object" && typeof p.thoughtSignature === "string")?.thoughtSignature;
1248+
1249+
if (signature) {
1250+
const newParts = content.parts.map((p: any) => {
1251+
if (p && typeof p === "object" && p.functionCall && !p.thoughtSignature) {
1252+
return { ...p, thoughtSignature: signature };
1253+
}
1254+
return p;
1255+
});
1256+
return { ...content, parts: newParts };
1257+
}
1258+
1259+
return content;
1260+
});
1261+
}
1262+
11801263
// For Claude models, filter out unsigned thinking blocks (required by Claude API)
11811264
// Attempts to restore signatures from cache for multi-turn conversations
11821265
// Handle both Gemini-style contents[] and Anthropic-style messages[] payloads.
@@ -1316,6 +1399,7 @@ export function prepareAntigravityRequest(
13161399
}
13171400

13181401
stripInjectedDebugFromRequestPayload(requestPayload);
1402+
sanitizeRequestPayloadForAntigravity(requestPayload);
13191403

13201404
const effectiveProjectId = projectId?.trim() || (headerStyle === "antigravity" ? generateSyntheticProjectId() : "");
13211405
resolvedProjectId = effectiveProjectId;
@@ -1506,7 +1590,7 @@ export async function transformAntigravityResponse(
15061590
// - If keep_thinking=true (but no debug): inject placeholder to trigger signature caching
15071591
// Both use the same injection path (injectDebugThinking) for consistent behavior
15081592
const debugText =
1509-
isDebugEnabled() && Array.isArray(debugLines) && debugLines.length > 0
1593+
isDebugTuiEnabled() && Array.isArray(debugLines) && debugLines.length > 0
15101594
? formatDebugLinesForThinking(debugLines)
15111595
: getKeepThinking()
15121596
? SYNTHETIC_THINKING_PLACEHOLDER
@@ -1553,6 +1637,8 @@ export async function transformAntigravityResponse(
15531637
});
15541638
}
15551639

1640+
const responseFallback = response.clone();
1641+
15561642
try {
15571643
const headers = new Headers(response.headers);
15581644
const text = await response.text();
@@ -1567,12 +1653,16 @@ export async function transformAntigravityResponse(
15671653

15681654
// Inject Debug Info
15691655
if (errorBody?.error) {
1656+
const rawErrorMessage =
1657+
typeof errorBody.error.message === "string" && errorBody.error.message.length > 0
1658+
? errorBody.error.message
1659+
: "Unknown error";
1660+
const errorType = detectErrorType(rawErrorMessage);
15701661
const debugInfo = `\n\n[Debug Info]\nRequested Model: ${requestedModel || "Unknown"}\nEffective Model: ${effectiveModel || "Unknown"}\nProject: ${projectId || "Unknown"}\nEndpoint: ${endpoint || "Unknown"}\nStatus: ${response.status}\nRequest ID: ${headers.get("x-request-id") || "N/A"}${toolDebugMissing !== undefined ? `\nTool Debug Missing: ${toolDebugMissing}` : ""}${toolDebugSummary ? `\nTool Debug Summary: ${toolDebugSummary}` : ""}${toolDebugPayload ? `\nTool Debug Payload: ${toolDebugPayload}` : ""}`;
15711662
const injectedDebug = debugText ? `\n\n${debugText}` : "";
1572-
errorBody.error.message = (errorBody.error.message || "Unknown error") + debugInfo + injectedDebug;
1663+
errorBody.error.message = rawErrorMessage + debugInfo + injectedDebug;
15731664

15741665
// Check if this is a recoverable thinking error - throw to trigger retry
1575-
const errorType = detectErrorType(errorBody.error.message || "");
15761666
if (errorType === "thinking_block_order") {
15771667
const recoveryError = new Error("THINKING_RECOVERY_NEEDED");
15781668
(recoveryError as any).recoveryType = errorType;
@@ -1694,11 +1784,15 @@ export async function transformAntigravityResponse(
16941784

16951785
return new Response(text, init);
16961786
} catch (error) {
1787+
if (error instanceof Error && error.message === "THINKING_RECOVERY_NEEDED") {
1788+
throw error;
1789+
}
1790+
16971791
logAntigravityDebugResponse(debugContext, response, {
16981792
error,
16991793
note: "Failed to transform Antigravity response",
17001794
});
1701-
return response;
1795+
return responseFallback;
17021796
}
17031797
}
17041798

0 commit comments

Comments
 (0)