From 783e271c167381497807ad7332b770cb5d8cb7e2 Mon Sep 17 00:00:00 2001 From: Phuong Lambert Date: Thu, 23 Jul 2026 16:33:03 +0700 Subject: [PATCH] feat(gemini): add Gemini 3.6 Flash tier routing and 3.5 Flash Lite Add gemini-3.6-flash tiered (high/medium/low) for Antigravity routing via upstreamModelId "gemini-3.6-flash-tiered(level)" + thinkingLevel, plus gemini-3.6-flash and gemini-3.5-flash-lite direct API models. - getModelUpstreamId: split (level) suffix before lookup, re-append after - Antigravity executor: preserve transformed body.model - MITM extractModel: parse thinkingLevel for tiered model (default medium) - Isolate Cloud Code endpoints: discovery (loadCodeAssist/onboardUser/ quota) on PROD cloudcode-pa, chat transport on daily-cloudcode-pa to bypass prod 429 --- cli/src/cli/menus/providers.js | 5 + open-sse/config/appConstants.js | 15 ++- open-sse/config/providerModels.js | 10 +- open-sse/executors/antigravity.js | 2 +- open-sse/providers/registry/antigravity.js | 9 +- open-sse/providers/shared.js | 2 +- open-sse/services/projectId.js | 15 ++- src/mitm/config.js | 43 +++++- src/mitm/server.js | 38 +----- src/shared/constants/cliTools.js | 7 +- src/sse/handlers/chat.js | 2 +- tests/__baseline__/known-fails.txt | 1 - tests/__baseline__/providers-baseline.json | 4 +- tests/unit/antigravity-retry-hook.test.js | 4 +- tests/unit/gemini-36-integration.test.js | 144 +++++++++++++++++++++ 15 files changed, 236 insertions(+), 65 deletions(-) create mode 100644 tests/unit/gemini-36-integration.test.js diff --git a/cli/src/cli/menus/providers.js b/cli/src/cli/menus/providers.js index 291ae674..7e28ec64 100644 --- a/cli/src/cli/menus/providers.js +++ b/cli/src/cli/menus/providers.js @@ -53,6 +53,9 @@ const PROVIDER_MODELS = { { id: "glm-4.7" }, ], ag: [ + { id: "gemini-3.6-flash-high" }, + { id: "gemini-3.6-flash-medium" }, + { id: "gemini-3.6-flash-low" }, { id: "gemini-3-flash-agent" }, { id: "gemini-3.5-flash-low" }, { id: "gemini-3.5-flash-extra-low" }, @@ -95,6 +98,8 @@ const PROVIDER_MODELS = { { id: "claude-3-5-sonnet-20241022" }, ], gemini: [ + { id: "gemini-3.6-flash" }, + { id: "gemini-3.5-flash-lite" }, { id: "gemini-3-pro-preview" }, { id: "gemini-2.5-pro" }, { id: "gemini-2.5-flash" }, diff --git a/open-sse/config/appConstants.js b/open-sse/config/appConstants.js index 2934fc88..5e3ec4be 100644 --- a/open-sse/config/appConstants.js +++ b/open-sse/config/appConstants.js @@ -134,10 +134,19 @@ export const ANTIGRAVITY_HEADERS = { "User-Agent": ANTIGRAVITY_IDE_USER_AGENT }; -// Cloud Code Assist API +// Cloud Code Assist API endpoints differ by client ecosystem. export const CLOUD_CODE_API = { - loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", - onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + "gemini-cli": { + loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + }, + // Project discovery (loadCodeAssist/onboardUser) stays on PROD — the daily host + // rejects these auth/onboarding calls. Only chat traffic uses the daily host + // (see transport.apiEndpoint in registry/antigravity.js, set to bypass prod 429). + antigravity: { + loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + }, }; export const LOAD_CODE_ASSIST_HEADERS = { diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 108806e8..860f7097 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -4,7 +4,6 @@ import REGISTRY from "../providers/registry/index.js"; import { PROVIDER_MODELS } from "../providers/index.js"; import { modelQuotaFamily, modelStrip, modelTargetFormat, normalizeModelId } from "../providers/models/schema.js"; import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js"; - export { PROVIDER_MODELS }; @@ -70,8 +69,13 @@ export function getModelUpstreamId(aliasOrId, modelId) { const baseId = suffix ? modelId.slice(0, sufMatch.index).trim() : modelId; const models = PROVIDER_MODELS[aliasOrId]; const found = findModel(models, baseId, aliasOrId); - if (found?.upstreamModelId) return found.upstreamModelId + suffix; - if (found?.id) return found.id + suffix; + const resolvedId = found?.upstreamModelId || found?.id; + if (resolvedId) { + const presetMatch = resolvedId.match(/\([^()]+\)\s*$/); + const presetSuffix = presetMatch?.[0] || ""; + const resolvedBase = presetSuffix ? resolvedId.slice(0, presetMatch.index).trim() : resolvedId; + return resolvedBase + (suffix || presetSuffix); + } if (aliasOrId === "cx" && typeof baseId === "string" && baseId.endsWith(CODEX_REVIEW_SUFFIX)) { return baseId.slice(0, -CODEX_REVIEW_SUFFIX.length) + suffix; } diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index f669e3a7..9ca61853 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -264,7 +264,7 @@ export class AntigravityExecutor extends BaseExecutor { return { ...body, project: projectId, - model: model, + model: body.model || model, userAgent: "antigravity", requestType: "agent", requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }), diff --git a/open-sse/providers/registry/antigravity.js b/open-sse/providers/registry/antigravity.js index 6818e764..a4ca7346 100644 --- a/open-sse/providers/registry/antigravity.js +++ b/open-sse/providers/registry/antigravity.js @@ -36,6 +36,7 @@ export default { }, }, usage: { + // Discovery (quota/project) on PROD; daily host rejects these. quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", loadProjectApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", tokenUrl: "https://oauth2.googleapis.com/token", @@ -44,9 +45,9 @@ export default { clientSecret: "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", }, models: [ - { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)" }, - { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)" }, - { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)" }, + { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", upstreamModelId: "gemini-3.6-flash-tiered(high)" }, + { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", upstreamModelId: "gemini-3.6-flash-tiered(medium)" }, + { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", upstreamModelId: "gemini-3.6-flash-tiered(low)" }, { id: "gemini-3.5-flash-high", name: "Gemini 3.5 Flash (High)" }, { id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" }, { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" }, @@ -71,7 +72,7 @@ export default { "https://www.googleapis.com/auth/cclog", "https://www.googleapis.com/auth/experimentsandconfigs", ], - apiEndpoint: "https://cloudcode-pa.googleapis.com", + apiEndpoint: "https://daily-cloudcode-pa.googleapis.com", apiVersion: "v1internal", loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", diff --git a/open-sse/providers/shared.js b/open-sse/providers/shared.js index 6f6a2c20..85b256a1 100644 --- a/open-sse/providers/shared.js +++ b/open-sse/providers/shared.js @@ -58,7 +58,7 @@ export const ANTHROPIC_COMPAT_BASE = "https://api.anthropic.com/v1"; // Keep this static even when 9router runs on Linux: the provider profile is // intentionally matching the IDE client, not the server host. export const ANTIGRAVITY_IDE_VERSION = "2.1.1"; -export const ANTIGRAVITY_IDE_BASE_URL = "https://cloudcode-pa.googleapis.com"; +export const ANTIGRAVITY_IDE_BASE_URL = "https://daily-cloudcode-pa.googleapis.com"; export const ANTIGRAVITY_IDE_USER_AGENT = `antigravity/ide/${ANTIGRAVITY_IDE_VERSION} darwin/arm64`; // Antigravity OAuth client credentials (public CLI client — duplicated in usage.js + src/lib/oauth) diff --git a/open-sse/services/projectId.js b/open-sse/services/projectId.js index f9a24e1a..3801ac69 100644 --- a/open-sse/services/projectId.js +++ b/open-sse/services/projectId.js @@ -83,7 +83,7 @@ startCacheCleanup(); * @param {string} accessToken - Valid OAuth access token * @returns {Promise} Real project ID or null */ -export async function getProjectIdForConnection(connectionId, accessToken) { +export async function getProjectIdForConnection(connectionId, accessToken, provider = "gemini-cli") { if (!connectionId || !accessToken) return null; // Return cached value if still fresh @@ -102,7 +102,7 @@ export async function getProjectIdForConnection(connectionId, accessToken) { const promise = (async () => { try { - const projectId = await fetchProjectId(accessToken, controller.signal); + const projectId = await fetchProjectId(accessToken, controller.signal, provider); if (projectId) { projectIdCache.set(connectionId, {projectId, fetchedAt: Date.now()}); return projectId; @@ -155,8 +155,9 @@ export function removeConnection(connectionId) { * @param {AbortSignal} signal * @returns {Promise} */ -async function fetchProjectId(accessToken, signal) { - const response = await fetch(CLOUD_CODE_API.loadCodeAssist, { +async function fetchProjectId(accessToken, signal, provider) { + const endpoints = CLOUD_CODE_API[provider] || CLOUD_CODE_API["gemini-cli"]; + const response = await fetch(endpoints.loadCodeAssist, { method: "POST", headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, body: JSON.stringify({ metadata: LOAD_CODE_ASSIST_METADATA }), @@ -185,7 +186,7 @@ async function fetchProjectId(accessToken, signal) { } } - return onboardUser(accessToken, tierID, signal); + return onboardUser(accessToken, tierID, signal, endpoints); } /** @@ -196,7 +197,7 @@ async function fetchProjectId(accessToken, signal) { * @param {AbortSignal} externalSignal – propagated from the connection's AbortController * @returns {Promise} */ -async function onboardUser(accessToken, tierID, externalSignal) { +async function onboardUser(accessToken, tierID, externalSignal, endpoints) { console.log(`[ProjectId] Onboarding user with tier: ${tierID}`); const reqBody = { tierId: tierID, metadata: LOAD_CODE_ASSIST_METADATA }; @@ -213,7 +214,7 @@ async function onboardUser(accessToken, tierID, externalSignal) { externalSignal?.addEventListener("abort", forwardAbort); try { - const response = await fetch(CLOUD_CODE_API.onboardUser, { + const response = await fetch(endpoints.onboardUser, { method: "POST", headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, body: JSON.stringify(reqBody), diff --git a/src/mitm/config.js b/src/mitm/config.js index e109ec95..ffbe07e1 100644 --- a/src/mitm/config.js +++ b/src/mitm/config.js @@ -86,4 +86,45 @@ function getToolForHost(host) { return null; } -module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost }; +function isBinaryData(buffer) { + if (!buffer || buffer.length === 0) return false; + const sample = buffer.slice(0, Math.min(100, buffer.length)); + let nonPrintable = 0; + for (let i = 0; i < sample.length; i++) { + const byte = sample[i]; + if (byte < 0x20 && byte !== 0x09 && byte !== 0x0A && byte !== 0x0D) { + nonPrintable++; + } + if (byte > 0x7E) nonPrintable++; + } + return (nonPrintable / sample.length) > 0.3; +} + +// Extract model from URL path (Gemini), body (OpenAI/Anthropic), or Kiro conversationState. +function extractModel(url, body) { + const urlMatch = url.match(/\/models\/([^/:]+)/); + const urlModel = urlMatch?.[1] || null; + + if (isBinaryData(body)) return urlModel; + + try { + const parsed = JSON.parse(body.toString()); + if (parsed.conversationState) { + return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null; + } + const model = urlModel || parsed.model || null; + if (String(model).replace(/^models\//, "") === "gemini-3.6-flash-tiered") { + const rawLevel = parsed.request?.generationConfig?.thinkingConfig?.thinkingLevel + || parsed.generationConfig?.thinkingConfig?.thinkingLevel; + const level = ["high", "medium", "low"].includes(String(rawLevel).toLowerCase()) + ? String(rawLevel).toLowerCase() + : "medium"; + return `gemini-3.6-flash-${level}`; + } + return model; + } catch { + return urlModel; + } +} + +module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost, extractModel }; diff --git a/src/mitm/server.js b/src/mitm/server.js index d675e6a3..ce432eee 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -7,7 +7,7 @@ const dns = require("dns"); const { promisify } = require("util"); const { execSync } = require("child_process"); const { log, err, dumpRequest, createResponseDumper, clearDumpDir } = require("./logger"); -const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost } = require("./config"); +const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost, extractModel } = require("./config"); const { DATA_DIR, MITM_DIR } = require("./paths"); const { generateCert, getCertForDomain } = require("./cert/generate"); const { getMitmAlias } = require("./dbReader"); @@ -96,42 +96,6 @@ function collectBodyRaw(req) { }); } -// Extract model from URL path (Gemini), body (OpenAI/Anthropic), or Kiro conversationState -function extractModel(url, body) { - const urlMatch = url.match(/\/models\/([^/:]+)/); - if (urlMatch) return urlMatch[1]; - - // Skip parsing if body is binary (AWS EventStream, Protocol Buffers, etc.) - if (isBinaryData(body)) return null; - - try { - const parsed = JSON.parse(body.toString()); - if (parsed.conversationState) { - return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null; - } - return parsed.model || null; - } catch { return null; } -} - -// Detect binary data vs JSON text -function isBinaryData(buffer) { - if (!buffer || buffer.length === 0) return false; - // AWS EventStream signature: first 4 bytes = frame length (big-endian uint32) - // Check for non-printable chars in first 100 bytes (common in binary protocols) - const sample = buffer.slice(0, Math.min(100, buffer.length)); - let nonPrintable = 0; - for (let i = 0; i < sample.length; i++) { - const byte = sample[i]; - // Count non-ASCII printable chars (excluding whitespace) - if (byte < 0x20 && byte !== 0x09 && byte !== 0x0A && byte !== 0x0D) { - nonPrintable++; - } - if (byte > 0x7E) nonPrintable++; - } - // If >30% non-printable, treat as binary - return (nonPrintable / sample.length) > 0.3; -} - function getMappedModel(tool, model) { if (!model) return null; try { diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index b501e884..81dcd9d2 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -8,9 +8,12 @@ export const MITM_TOOLS = { description: "Google Antigravity IDE with MITM", configType: "mitm", mitmDomain: "daily-cloudcode-pa.googleapis.com", - modelAliases: ["gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"], + modelAliases: ["gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"], defaultModels: [ - { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium) / Default", alias: "gemini-3.5-flash-low" }, + { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", alias: "gemini-3.6-flash-high" }, + { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", alias: "gemini-3.6-flash-medium" }, + { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", alias: "gemini-3.6-flash-low" }, + { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium) / Default", alias: "gemini-3.5-flash-low", mandatory: true }, { id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)", alias: "gemini-3-flash-agent" }, { id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)", alias: "gemini-3.5-flash-extra-low" }, { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)", alias: "gemini-3.1-pro-low" }, diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 77f450c1..af2914a4 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -219,7 +219,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re // Ensure real project ID is available for providers that need it (P0 fix: cold miss) if ((provider === "antigravity" || provider === "gemini-cli") && !refreshedCredentials.projectId) { - const pid = await getProjectIdForConnection(credentials.connectionId, refreshedCredentials.accessToken); + const pid = await getProjectIdForConnection(credentials.connectionId, refreshedCredentials.accessToken, provider); if (pid) { refreshedCredentials.projectId = pid; // Persist to DB in background so subsequent requests have it immediately diff --git a/tests/__baseline__/known-fails.txt b/tests/__baseline__/known-fails.txt index 9ffd63f9..49fd6a11 100644 --- a/tests/__baseline__/known-fails.txt +++ b/tests/__baseline__/known-fails.txt @@ -1,4 +1,3 @@ -tests/unit/antigravity-mitm.test.js :: Antigravity MITM model handling flags the out-of-box agent/Default model mandatory tests/unit/claude-header-forwarding.test.js :: proxyAwareFetch — api.anthropic.com routing routes api.anthropic.com to gotScraping (non-streaming) and returns ok response tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import extracts tokens using exact keys tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import falls back to fuzzy key matching on macOS when exact keys are missing diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index 232c5d59..e8f64967 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -25,7 +25,7 @@ }, "antigravity": { "baseUrls": [ - "https://cloudcode-pa.googleapis.com" + "https://daily-cloudcode-pa.googleapis.com" ], "format": "antigravity", "headers": { @@ -881,4 +881,4 @@ }, "format": "openai" } -} \ No newline at end of file +} diff --git a/tests/unit/antigravity-retry-hook.test.js b/tests/unit/antigravity-retry-hook.test.js index 989dd88f..adb8bd25 100644 --- a/tests/unit/antigravity-retry-hook.test.js +++ b/tests/unit/antigravity-retry-hook.test.js @@ -67,8 +67,8 @@ describe("antigravity computeRetryDelay hook (D3)", () => { expect(out.request.tools[0].functionDeclarations.map(fn => fn.name)).toEqual(["read_file"]); }); - it("registry uses the official IDE cloudcode host and user agent", () => { - expect(antigravity.transport.baseUrls).toEqual(["https://cloudcode-pa.googleapis.com"]); + it("registry uses the daily IDE cloudcode host and user agent", () => { + expect(antigravity.transport.baseUrls).toEqual(["https://daily-cloudcode-pa.googleapis.com"]); expect(antigravity.transport.headers["User-Agent"]).toBe("antigravity/ide/2.1.1 darwin/arm64"); }); diff --git a/tests/unit/gemini-36-integration.test.js b/tests/unit/gemini-36-integration.test.js new file mode 100644 index 00000000..e401f923 --- /dev/null +++ b/tests/unit/gemini-36-integration.test.js @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { getModelUpstreamId } from "../../open-sse/config/providerModels.js"; +import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js"; +import { applyThinking, stripThinkingSuffix } from "../../open-sse/translator/concerns/thinkingUnified.js"; +import antigravity from "../../open-sse/providers/registry/antigravity.js"; +import geminiCli from "../../open-sse/providers/registry/gemini-cli.js"; +import gemini from "../../open-sse/providers/registry/gemini.js"; +import { MODEL_PRICING } from "../../open-sse/providers/pricing.js"; +import { + getProjectIdForConnection, + removeConnection, +} from "../../open-sse/services/projectId.js"; + +const require = createRequire(import.meta.url); +const mitmConfig = require("../../src/mitm/config.js"); +const here = dirname(fileURLToPath(import.meta.url)); + +function cloudCodeResponse(projectId) { + return { + ok: true, + json: async () => ({ cloudaicompanionProject: { id: projectId } }), + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Gemini Cloud Code endpoint isolation", () => { + it("keeps Gemini CLI on the official cloudcode host", async () => { + const connectionId = "gemini-cli-endpoint-test"; + const fetchMock = vi.fn(async () => cloudCodeResponse("gemini-project")); + vi.stubGlobal("fetch", fetchMock); + + await getProjectIdForConnection(connectionId, "token", "gemini-cli"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + expect.objectContaining({ method: "POST" }) + ); + expect(geminiCli.transport.baseUrl).toBe("https://cloudcode-pa.googleapis.com/v1internal"); + removeConnection(connectionId); + }); + + it("uses the prod cloudcode host for Antigravity discovery but daily for chat", async () => { + const connectionId = "antigravity-endpoint-test"; + const fetchMock = vi.fn(async () => cloudCodeResponse("antigravity-project")); + vi.stubGlobal("fetch", fetchMock); + + await getProjectIdForConnection(connectionId, "token", "antigravity"); + + // Discovery (loadCodeAssist) on PROD — daily host rejects auth/onboarding calls. + expect(fetchMock).toHaveBeenCalledWith( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + expect.objectContaining({ method: "POST" }) + ); + // Chat transport still uses the daily host to bypass prod 429. + expect(antigravity.transport.baseUrls).toEqual(["https://daily-cloudcode-pa.googleapis.com"]); + removeConnection(connectionId); + }); +}); + +describe("Gemini 3.6 Antigravity tiers", () => { + it.each(["high", "medium", "low"])( + "maps the %s tier to the shared upstream model with matching thinking level", + (tier) => { + const publicModel = `gemini-3.6-flash-${tier}`; + const upstreamModel = getModelUpstreamId("ag", publicModel); + const body = { + model: stripThinkingSuffix(upstreamModel), + request: { + contents: [{ role: "user", parts: [{ text: "hello" }] }], + generationConfig: {}, + }, + }; + + applyThinking("antigravity", upstreamModel, body, "antigravity"); + const finalBody = new AntigravityExecutor().transformRequest( + publicModel, + body, + true, + { projectId: "project", connectionId: "connection" } + ); + + expect(upstreamModel).toBe(`gemini-3.6-flash-tiered(${tier})`); + expect(finalBody.model).toBe("gemini-3.6-flash-tiered"); + expect(finalBody.request.generationConfig.thinkingConfig).toEqual({ + thinkingLevel: tier, + includeThoughts: true, + }); + } + ); +}); + +describe("Gemini 3.6 MITM model extraction", () => { + it("exports the model extractor from the side-effect-free MITM config module", () => { + expect(mitmConfig.extractModel).toBeTypeOf("function"); + }); + + it.each(["high", "medium", "low"])("extracts the %s thinking tier", (tier) => { + const body = Buffer.from(JSON.stringify({ + request: { generationConfig: { thinkingConfig: { thinkingLevel: tier } } }, + })); + + expect(mitmConfig.extractModel( + "/v1internal/models/gemini-3.6-flash-tiered:streamGenerateContent", + body + )).toBe(`gemini-3.6-flash-${tier}`); + }); + + it("defaults invalid or missing thinking levels to medium", () => { + const body = Buffer.from(JSON.stringify({ + request: { generationConfig: { thinkingConfig: { thinkingLevel: "unknown" } } }, + })); + + expect(mitmConfig.extractModel( + "/v1internal/models/gemini-3.6-flash-tiered:streamGenerateContent", + body + )).toBe("gemini-3.6-flash-medium"); + }); +}); + +describe("Gemini 3.6 catalogs and pricing", () => { + it("exposes the direct Gemini API models and their pricing", () => { + const ids = gemini.models.map((model) => model.id); + expect(ids).toContain("gemini-3.6-flash"); + expect(ids).toContain("gemini-3.5-flash-lite"); + expect(MODEL_PRICING["gemini-3.6-flash"]).toMatchObject({ input: 1.5, output: 7.5 }); + expect(MODEL_PRICING["gemini-3.5-flash-lite"]).toMatchObject({ input: 0.3, output: 2.5 }); + }); + + it("keeps the standalone CLI Gemini catalog synchronized", () => { + const source = readFileSync(join(here, "../../cli/src/cli/menus/providers.js"), "utf8"); + const geminiCatalog = source.match(/\n gemini: \[([\s\S]*?)\n \],/)?.[1] || ""; + + expect(geminiCatalog).toContain("gemini-3.6-flash"); + expect(geminiCatalog).toContain("gemini-3.5-flash-lite"); + }); +});