Skip to content
This repository was archived by the owner on Feb 28, 2026. It is now read-only.

Commit b360cc1

Browse files
committed
feat(plugin): add Gemini 3 Flash support and comprehensive tests
1 parent 26be7e9 commit b360cc1

4 files changed

Lines changed: 221 additions & 60 deletions

File tree

src/plugin/request.test.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { expect, test, describe } from "bun:test";
2-
import { prepareAntigravityRequest } from "./request";
2+
import { prepareAntigravityRequest, isGenerativeLanguageRequest } from "./request";
3+
import { overrideEndpointForRequest } from "./fetch-wrapper";
4+
import { CODE_ASSIST_ENDPOINT, CODE_ASSIST_ENDPOINT_FALLBACKS } from "../constants";
35

46
describe("Interleaved Thinking Headers", () => {
57
test("adds interleaved thinking header for claude thinking models", async () => {
@@ -60,3 +62,110 @@ describe("Interleaved Thinking Headers", () => {
6062
expect(headers.get("anthropic-beta")).toBe("interleaved-thinking-2025-05-14");
6163
});
6264
});
65+
66+
describe("URL Transformation", () => {
67+
test("isGenerativeLanguageRequest detects Gemini API URLs", () => {
68+
expect(isGenerativeLanguageRequest("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent")).toBe(true);
69+
expect(isGenerativeLanguageRequest("https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:generateContent")).toBe(true);
70+
expect(isGenerativeLanguageRequest("https://example.com/api")).toBe(false);
71+
expect(isGenerativeLanguageRequest("https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent")).toBe(false);
72+
});
73+
74+
test("transforms Gemini API URL to CODE_ASSIST_ENDPOINT", async () => {
75+
const originalUrl = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent";
76+
77+
const result = await prepareAntigravityRequest(
78+
originalUrl,
79+
{ method: "POST", body: JSON.stringify({ contents: [] }) },
80+
"dummy-token",
81+
"dummy-project"
82+
);
83+
84+
expect(result.request).toBe(`${CODE_ASSIST_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`);
85+
});
86+
87+
test("transforms non-streaming URL correctly", async () => {
88+
const originalUrl = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent";
89+
90+
const result = await prepareAntigravityRequest(
91+
originalUrl,
92+
{ method: "POST", body: JSON.stringify({ contents: [] }) },
93+
"dummy-token",
94+
"dummy-project"
95+
);
96+
97+
expect(result.request).toBe(`${CODE_ASSIST_ENDPOINT}/v1internal:generateContent`);
98+
expect(result.streaming).toBe(false);
99+
});
100+
101+
test("sets streaming flag for streamGenerateContent action", async () => {
102+
const streamingUrl = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent";
103+
const nonStreamingUrl = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent";
104+
105+
const streamingResult = await prepareAntigravityRequest(
106+
streamingUrl,
107+
{ method: "POST", body: JSON.stringify({ contents: [] }) },
108+
"dummy-token",
109+
"dummy-project"
110+
);
111+
112+
const nonStreamingResult = await prepareAntigravityRequest(
113+
nonStreamingUrl,
114+
{ method: "POST", body: JSON.stringify({ contents: [] }) },
115+
"dummy-token",
116+
"dummy-project"
117+
);
118+
119+
expect(streamingResult.streaming).toBe(true);
120+
expect(nonStreamingResult.streaming).toBe(false);
121+
});
122+
123+
test("extracts and returns requested model from URL", async () => {
124+
const url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:streamGenerateContent";
125+
126+
const result = await prepareAntigravityRequest(
127+
url,
128+
{ method: "POST", body: JSON.stringify({ contents: [] }) },
129+
"dummy-token",
130+
"dummy-project"
131+
);
132+
133+
expect(result.requestedModel).toBe("gemini-3-flash");
134+
});
135+
});
136+
137+
describe("Endpoint Fallback Override", () => {
138+
test("overrideEndpointForRequest replaces base URL with string input", () => {
139+
const preparedUrl = "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse";
140+
const autopushEndpoint = CODE_ASSIST_ENDPOINT_FALLBACKS[1];
141+
142+
const result = overrideEndpointForRequest(preparedUrl, autopushEndpoint!);
143+
144+
expect(result).toBe("https://autopush-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse");
145+
});
146+
147+
test("overrideEndpointForRequest replaces base URL with URL input", () => {
148+
const preparedUrl = new URL("https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse");
149+
const prodEndpoint = CODE_ASSIST_ENDPOINT_FALLBACKS[2];
150+
151+
const result = overrideEndpointForRequest(preparedUrl, prodEndpoint!);
152+
153+
expect(result).toBe("https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse");
154+
});
155+
156+
test("overrideEndpointForRequest works with Request object", () => {
157+
const preparedUrl = "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse";
158+
const request = new Request(preparedUrl, { method: "POST" });
159+
const autopushEndpoint = CODE_ASSIST_ENDPOINT_FALLBACKS[1];
160+
161+
const result = overrideEndpointForRequest(request, autopushEndpoint!) as Request;
162+
163+
expect(result.url).toBe("https://autopush-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse");
164+
});
165+
166+
test("fallback endpoints are in correct order", () => {
167+
expect(CODE_ASSIST_ENDPOINT_FALLBACKS[0]).toContain("daily");
168+
expect(CODE_ASSIST_ENDPOINT_FALLBACKS[1]).toContain("autopush");
169+
expect(CODE_ASSIST_ENDPOINT_FALLBACKS[2]).toBe("https://cloudcode-pa.googleapis.com");
170+
});
171+
});

src/plugin/request.ts

Lines changed: 59 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ function transformSseLine(line: string, onError?: (body: GeminiApiBody) => Gemin
7171
}
7272
try {
7373
let parsed = JSON.parse(json) as unknown;
74-
74+
7575
// Handle array-wrapped responses
7676
if (Array.isArray(parsed)) {
7777
parsed = parsed.find((item) => typeof item === "object" && item !== null);
@@ -127,7 +127,7 @@ export function createSseTransformStream(onError?: (body: GeminiApiBody) => Gemi
127127
if (!sessionId || !family) return;
128128
const response = body.response as any;
129129
if (!response?.candidates) return;
130-
130+
131131
response.candidates.forEach((candidate: any, index: number) => {
132132
if (candidate.groundingMetadata) {
133133
log.debug("SSE Grounding metadata found", { groundingMetadata: candidate.groundingMetadata });
@@ -199,17 +199,17 @@ export async function prepareAntigravityRequest(
199199
new Headers(init.headers).forEach((value, key) => reqHeaders.set(key, value));
200200
}
201201
requestInit.headers = reqHeaders;
202-
202+
203203
// If body isn't in init, try to get it from request
204204
if (!originalBody && input.body) {
205205
// We need to clone to avoid consuming the original request if possible,
206206
// but standard Request cloning is sync.
207207
// We'll try to read text if we can.
208208
try {
209-
// Note: If input is a Request object that has been used, this might fail.
210-
// But usually in this context it's fresh.
211-
const cloned = input.clone();
212-
originalBody = await cloned.text();
209+
// Note: If input is a Request object that has been used, this might fail.
210+
// But usually in this context it's fresh.
211+
const cloned = input.clone();
212+
originalBody = await cloned.text();
213213
} catch (e) {
214214
// If clone fails (e.g. body used), we might be in trouble or it's empty.
215215
}
@@ -256,33 +256,33 @@ export async function prepareAntigravityRequest(
256256

257257
if (isWrapped) {
258258
if (isClaudeModel) {
259-
const context: TransformContext = {
260-
model: effectiveModel,
261-
family: getModelFamily(effectiveModel),
262-
projectId: (parsedBody.project as string) || projectId,
263-
streaming,
264-
requestId: generateRequestId(),
265-
sessionId: getSessionId(),
266-
};
267-
const innerRequest = parsedBody.request as Record<string, unknown>;
268-
const result = transformClaudeRequest(context, innerRequest);
269-
body = result.body;
270-
transformDebugInfo = result.debugInfo;
271-
272-
if (transformDebugInfo) {
273-
log.debug("Using transformer (wrapped)", { transformer: transformDebugInfo.transformer, model: effectiveModel, family: context.family, toolCount: transformDebugInfo.toolCount });
274-
}
259+
const context: TransformContext = {
260+
model: effectiveModel,
261+
family: getModelFamily(effectiveModel),
262+
projectId: (parsedBody.project as string) || projectId,
263+
streaming,
264+
requestId: generateRequestId(),
265+
sessionId: getSessionId(),
266+
};
267+
const innerRequest = parsedBody.request as Record<string, unknown>;
268+
const result = transformClaudeRequest(context, innerRequest);
269+
body = result.body;
270+
transformDebugInfo = result.debugInfo;
271+
272+
if (transformDebugInfo) {
273+
log.debug("Using transformer (wrapped)", { transformer: transformDebugInfo.transformer, model: effectiveModel, family: context.family, toolCount: transformDebugInfo.toolCount });
274+
}
275275
} else {
276-
const wrappedBody = {
276+
const wrappedBody = {
277277
...parsedBody,
278278
model: effectiveModel,
279279
userAgent: "antigravity",
280280
requestId: generateRequestId(),
281-
} as Record<string, unknown>;
282-
if (wrappedBody.request && typeof wrappedBody.request === "object") {
281+
} as Record<string, unknown>;
282+
if (wrappedBody.request && typeof wrappedBody.request === "object") {
283283
(wrappedBody.request as Record<string, unknown>).sessionId = getSessionId();
284-
}
285-
body = JSON.stringify(wrappedBody);
284+
}
285+
body = JSON.stringify(wrappedBody);
286286
}
287287
} else {
288288
const context: TransformContext = {
@@ -369,22 +369,22 @@ export async function transformAntigravityResponse(
369369
return response;
370370
}
371371

372-
const errorHandler = (body: GeminiApiBody): GeminiApiBody | null => {
373-
const previewErrorFixed = rewriteGeminiPreviewAccessError(body, response.status, requestedModel);
374-
const rateLimitErrorFixed = rewriteGeminiRateLimitError(body);
372+
const errorHandler = (body: GeminiApiBody): GeminiApiBody | null => {
373+
const previewErrorFixed = rewriteGeminiPreviewAccessError(body, response.status, requestedModel);
374+
const rateLimitErrorFixed = rewriteGeminiRateLimitError(body);
375375

376-
const patched = previewErrorFixed ?? rateLimitErrorFixed;
376+
const patched = previewErrorFixed ?? rateLimitErrorFixed;
377377

378-
if (previewErrorFixed?.error) {
379-
client.tui.showToast({
380-
body: { message: previewErrorFixed.error.message ?? "You need access to gemini 3", title: "Gemini 3 Access Required", variant: "error" }
381-
}).catch(() => {});
382-
}
378+
if (previewErrorFixed?.error) {
379+
client.tui.showToast({
380+
body: { message: previewErrorFixed.error.message ?? "You need access to gemini 3", title: "Gemini 3 Access Required", variant: "error" }
381+
}).catch(() => { });
382+
}
383383

384-
return patched;
385-
};
384+
return patched;
385+
};
386386

387-
if (streaming && response.ok && isEventStreamResponse && response.body) {
387+
if (streaming && response.ok && isEventStreamResponse && response.body) {
388388
logAntigravityDebugResponse(debugContext, response, {
389389
note: "Streaming SSE (passthrough mode)",
390390
});
@@ -419,25 +419,25 @@ export async function transformAntigravityResponse(
419419
if (sessionId && parsed) {
420420
const responseBody = parsed.response as any;
421421
if (responseBody?.candidates) {
422-
responseBody.candidates.forEach((candidate: any) => {
423-
if (candidate.groundingMetadata) {
424-
log.debug("Grounding metadata found", { groundingMetadata: candidate.groundingMetadata });
425-
}
426-
let fullText = "";
427-
let signature = "";
428-
if (candidate.content?.parts) {
429-
candidate.content.parts.forEach((part: any) => {
430-
if (part.thought) {
431-
if (part.text) fullText += part.text;
432-
if (part.thoughtSignature) signature = part.thoughtSignature;
433-
}
434-
});
435-
}
436-
if (fullText && signature) {
437-
cacheSignature(family, sessionId, fullText, signature);
438-
log.debug("Cached signature", { family, sessionId, textLen: fullText.length });
422+
responseBody.candidates.forEach((candidate: any) => {
423+
if (candidate.groundingMetadata) {
424+
log.debug("Grounding metadata found", { groundingMetadata: candidate.groundingMetadata });
425+
}
426+
let fullText = "";
427+
let signature = "";
428+
if (candidate.content?.parts) {
429+
candidate.content.parts.forEach((part: any) => {
430+
if (part.thought) {
431+
if (part.text) fullText += part.text;
432+
if (part.thoughtSignature) signature = part.thoughtSignature;
439433
}
440-
});
434+
});
435+
}
436+
if (fullText && signature) {
437+
cacheSignature(family, sessionId, fullText, signature);
438+
log.debug("Cached signature", { family, sessionId, textLen: fullText.length });
439+
}
440+
});
441441
}
442442
}
443443

@@ -487,7 +487,7 @@ export async function transformAntigravityResponse(
487487
variant: "error",
488488
},
489489
});
490-
} catch {}
490+
} catch { }
491491
}
492492

493493
if (streaming && response.ok && isEventStreamResponse) {

tests/integration/google-search.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,57 @@ describe("Google Search Tool", () => {
116116
});
117117
});
118118

119+
describe("with Gemini 3 Flash", () => {
120+
let sessionId: string;
121+
122+
beforeEach(async () => {
123+
if (process.env.CI) return;
124+
sessionId = await createSession(ctx);
125+
});
126+
127+
afterEach(async () => {
128+
if (process.env.CI) return;
129+
if (sessionId) {
130+
try {
131+
await deleteSession(ctx, sessionId);
132+
} catch {}
133+
}
134+
});
135+
136+
itSkipInCI("should enable google_search tool and return grounded results", async () => {
137+
const response = await sendPrompt(
138+
ctx,
139+
sessionId,
140+
"Search the web for today's top technology news.",
141+
{
142+
model: TEST_MODELS.gemini3Flash,
143+
tools: { google_search: true },
144+
}
145+
);
146+
147+
expect(response).not.toBeNull();
148+
expect(response!.info.role).toBe("assistant");
149+
150+
const assistantText = extractTextFromParts(response!.parts);
151+
expect(assistantText.length).toBeGreaterThan(0);
152+
});
153+
154+
itSkipInCI("should work without google_search tool", async () => {
155+
const response = await sendPrompt(
156+
ctx,
157+
sessionId,
158+
"What is the capital of France?",
159+
{
160+
model: TEST_MODELS.gemini3Flash,
161+
}
162+
);
163+
164+
expect(response).not.toBeNull();
165+
const assistantText = extractTextFromParts(response!.parts);
166+
expect(assistantText.toLowerCase()).toContain("paris");
167+
});
168+
});
169+
119170
describe("with Claude Sonnet via Antigravity", () => {
120171
let sessionId: string;
121172

tests/integration/setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface TestContext {
1111
export const TEST_MODELS = {
1212
gemini25Flash: { providerID: "google", modelID: "gemini-2.5-flash" },
1313
gemini3Pro: { providerID: "google", modelID: "gemini-3-pro-preview" },
14+
gemini3Flash: { providerID: "google", modelID: "gemini-3-flash" },
1415
claudeSonnet: { providerID: "google", modelID: "gemini-claude-sonnet-4-5-thinking-medium" },
1516
} as const;
1617

0 commit comments

Comments
 (0)