diff --git a/.env.example b/.env.example index 25112cd644..cf147c7f22 100644 --- a/.env.example +++ b/.env.example @@ -843,10 +843,11 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500 # Used by: open-sse/executors/antigravity.ts — escape hatch for multi-project setups. # OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=0 -# Adjust how Antigravity advertises remaining credits. Used by: -# open-sse/services/antigravityCredits.ts — accepts forced override strings. -# Default: empty (use upstream-reported credits). -#ANTIGRAVITY_CREDITS= +# Control Antigravity Google One AI credit usage. Used by: +# open-sse/services/antigravityCredits.ts — accepts off, retry, or always. +# off (default): never use credits; retry: use credits once after eligible quota 429; +# always: use credits on the first request (higher account and spend risk). +#ANTIGRAVITY_CREDITS=off # Override the path to the Antigravity CLI (agy) token file read by the # "auto-detect local login" import. Used by: diff --git a/changelog.d/fixes/6914-antigravity-server-side-tool-invocations.md b/changelog.d/fixes/6914-antigravity-server-side-tool-invocations.md deleted file mode 100644 index 858201625b..0000000000 --- a/changelog.d/fixes/6914-antigravity-server-side-tool-invocations.md +++ /dev/null @@ -1 +0,0 @@ -- fix(sse): set includeServerSideToolInvocations on Antigravity tool cloak decoys (#6914) diff --git a/changelog.d/fixes/8098-antigravity-protocol-fidelity.md b/changelog.d/fixes/8098-antigravity-protocol-fidelity.md new file mode 100644 index 0000000000..1c5b3974de --- /dev/null +++ b/changelog.d/fixes/8098-antigravity-protocol-fidelity.md @@ -0,0 +1 @@ +- **fix(antigravity):** preserve request/tool fidelity, make credits modes explicit, and separate runtime endpoints from discovery/bootstrap ([#8098](https://github.com/diegosouzapw/OmniRoute/pull/8098)) — thanks @nguyenha935 diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 0e1a9eccf3..174969573b 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -690,11 +690,6 @@ "count": 3 } }, - "tests/unit/antigravity-tool-cloaking.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, "tests/unit/api-key-reveal-route.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 8 @@ -2317,7 +2312,7 @@ }, "tests/unit/translator-openai-to-gemini.test.ts": { "@typescript-eslint/no-explicit-any": { - "count": 70 + "count": 68 } }, "tests/unit/translator-openai-to-kiro.test.ts": { @@ -2450,4 +2445,4 @@ "count": 5 } } -} +} \ No newline at end of file diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 53580f8b6c..f56d25181d 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -318,6 +318,8 @@ "src/shared/constants/sidebarVisibility/sections.ts": 806, "_rebaseline_2026_07_22_8056_headroom_minrows": "#8056 (@RaviTharuma, persist Headroom minRows) own growth: src/lib/db/compression.ts 850->866 (+16 HeadroomConfig+DEFAULT_HEADROOM_CONFIG+normalize/store in get/updateCompressionSettings) and open-sse/services/compression/strategySelector.ts 1054->1060 (+6 merge settings.headroom into stacked stepConfig). Cohesive settings-persistence + stacked-merge wiring at existing chokepoints, frozen at new size.", "_rebaseline_2026_07_22_8081_reasoning_placeholder_guard": "#8081 (@Dingding-leo) own growth: openai-responses.ts 1125->1137 (+12) restructuring the reasoning-placeholder guard so it skips only the empty content block and still emits finish_reason/tool_calls in the same chunk. Cohesive translator wiring; frozen at new size.", + "open-sse/services/usage/antigravity.ts": 802, + "_rebaseline_2026_07_22_fusion_8013_8098_antigravity": "Fusion of #8013 (backryun, catalog/IDE-CLI-split rewrite) + #8098 (nguyenha935, protocol-fidelity/fail-closed/credits/tool-cloaking): open-sse/services/usage/antigravity.ts NEW 802 (>cap 800, +2 — #8098 credits/tier usage service on #8013's profile-aware headers). Test growth (models-catalog-route 1605->1608, provider-models-route 1752->1757 from #8013 Gemini 3.6 catalog) tracked in testFrozen.", "_rebaseline_2026_07_22_8050_model_lockout_exact_family": "#8050 (@AndrianBalanescu) own growth: accountFallback.ts 1864->1892 (+28) — exact-vs-family model-lockout scoping (getModelLockKey/isModelLocked/clearModelLock/getModelLockoutInfo) so an Antigravity 404 for one bare model no longer hijacks the whole family cooldown. Cohesive lockout logic; frozen at new size." }, "testCap": 800, @@ -350,10 +352,10 @@ "tests/unit/grok-web.test.ts": 2437, "tests/unit/image-generation-handler.test.ts": 2019, "tests/unit/model-sync-route.test.ts": 1016, - "tests/unit/models-catalog-route.test.ts": 1605, + "tests/unit/models-catalog-route.test.ts": 1608, "tests/unit/oauth-providers-config.test.ts": 845, "tests/unit/perplexity-web.test.ts": 1355, - "tests/unit/provider-models-route.test.ts": 1752, + "tests/unit/provider-models-route.test.ts": 1757, "tests/unit/provider-validation-specialty.test.ts": 2980, "tests/unit/providers-page-utils.test.ts": 1294, "tests/unit/reasoning-cache.test.ts": 980, diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index ed7a762a3d..7afb4ec171 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -481,7 +481,7 @@ the global circuit breaker / connection cooldown / model lockout layers: - Antigravity 429 engine: `open-sse/services/antigravity429Engine.ts` (rotates identity, scrubs response headers, drives credits/version tracking via `antigravityCredits.ts`, `antigravityHeaderScrub.ts`, `antigravityHeaders.ts`, - `antigravityIdentity.ts`, `antigravityObfuscation.ts`, `antigravityVersion.ts`) + `antigravityIdentity.ts`, `antigravityVersion.ts`) - ModelScope quota policy: `open-sse/services/modelscopePolicy.ts` - Claude Code CCH (Compatibility Channel Handshake): `open-sse/services/claudeCodeCCH.ts`, plus `claudeCodeCompatible.ts`, `claudeCodeConstraints.ts`, `claudeCodeExtraRemap.ts`, diff --git a/docs/i18n/pt-BR/docs/architecture/ARCHITECTURE.md b/docs/i18n/pt-BR/docs/architecture/ARCHITECTURE.md index 06bb8dbea0..ed3b8add5a 100644 --- a/docs/i18n/pt-BR/docs/architecture/ARCHITECTURE.md +++ b/docs/i18n/pt-BR/docs/architecture/ARCHITECTURE.md @@ -462,7 +462,7 @@ camadas globais de disjuntor / cooldown de conexão / bloqueio de modelo: - Motor Antigravity 429: `open-sse/services/antigravity429Engine.ts` (rotaciona identidade, limpa cabeçalhos de resposta, controla créditos/rastreamento de versão via `antigravityCredits.ts`, `antigravityHeaderScrub.ts`, `antigravityHeaders.ts`, - `antigravityIdentity.ts`, `antigravityObfuscation.ts`, `antigravityVersion.ts`) + `antigravityIdentity.ts`, `antigravityVersion.ts`) - Política de cota ModelScope: `open-sse/services/modelscopePolicy.ts` - Claude Code CCH (Handshake de Canal de Compatibilidade): `open-sse/services/claudeCodeCCH.ts`, além de `claudeCodeCompatible.ts`, `claudeCodeConstraints.ts`, `claudeCodeExtraRemap.ts`, diff --git a/docs/i18n/zh-CN/docs/architecture/ARCHITECTURE.md b/docs/i18n/zh-CN/docs/architecture/ARCHITECTURE.md index 98d801e5a3..eba7d6ad13 100644 --- a/docs/i18n/zh-CN/docs/architecture/ARCHITECTURE.md +++ b/docs/i18n/zh-CN/docs/architecture/ARCHITECTURE.md @@ -479,7 +479,7 @@ FSM 状态转换反馈到 Auto Combo 的评分中,使后台/自动化任务偏 - Antigravity 429 引擎:`open-sse/services/antigravity429Engine.ts`(轮换 身份、清洗响应头,通过 `antigravityCredits.ts`、`antigravityHeaderScrub.ts`、 - `antigravityHeaders.ts`、`antigravityIdentity.ts`、`antigravityObfuscation.ts`、 + `antigravityHeaders.ts`、`antigravityIdentity.ts`、 `antigravityVersion.ts` 驱动积分/版本追踪) - ModelScope 配额策略:`open-sse/services/modelscopePolicy.ts` - Claude Code CCH(兼容通道握手):`open-sse/services/claudeCodeCCH.ts`, diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index c34f1ad63f..d3dd45fe02 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -445,7 +445,7 @@ detection above). | `COMPRESSION_PREFIX_FREEZE_THRESHOLD` | `3` | `open-sse/services/compression/prefixFreeze.ts` | Observations of a system prompt before it is treated as a frozen stable prefix. | | `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. | | `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | Escape hatch: allow request body to override the Antigravity project field. | -| `ANTIGRAVITY_CREDITS` | _(unset)_ | `open-sse/services/antigravityCredits.ts` | Override Antigravity's advertised remaining credits (testing / forced values). | +| `ANTIGRAVITY_CREDITS` | `off` | `open-sse/services/antigravityCredits.ts` | Google One AI credits policy: `off` never injects credits, `retry` injects once after an eligible quota 429, and `always` injects on the first request. | | `AGY_TOKEN_FILE` | `~/.gemini/antigravity-cli/antigravity-oauth-token` | `src/app/api/providers/agy-auth/apply-local/route.ts` | Override the Antigravity CLI (agy) token-file path for the auto-detect local login import. | ### OAuth CLI Bridge (Internal) diff --git a/docs/security/STEALTH_GUIDE.md b/docs/security/STEALTH_GUIDE.md index 43043ce99b..dd737cb764 100644 --- a/docs/security/STEALTH_GUIDE.md +++ b/docs/security/STEALTH_GUIDE.md @@ -6,7 +6,7 @@ lastUpdated: 2026-06-28 # Stealth Guide -> **Source of truth:** `open-sse/utils/tlsClient.ts`, `open-sse/services/{chatgptTlsClient,claudeCodeCCH,claudeCodeFingerprint,claudeCodeObfuscation,claudeCodeCompatible,antigravityObfuscation}.ts`, `open-sse/config/cliFingerprints.ts`, `src/mitm/` +> **Source of truth:** `open-sse/utils/tlsClient.ts`, `open-sse/services/{chatgptTlsClient,claudeCodeCCH,claudeCodeFingerprint,claudeCodeObfuscation,claudeCodeCompatible}.ts`, `open-sse/config/cliFingerprints.ts`, `src/mitm/` > **Last updated:** 2026-06-28 — v3.8.40 > **Audience:** Engineers maintaining provider-specific stealth integrations. @@ -107,9 +107,7 @@ Sister modules in the same bundle: ## Antigravity Stealth -### `antigravityObfuscation.ts` - -Same zero-width-joiner trick as Claude Code, but with an expanded word list that also masks: `claude code`, `claude-code`, `kilo code`, `kilocode`, **`omniroute`**. Mirrors ZeroGravity's `ZEROGRAVITY_SENSITIVE_WORDS` and CLIProxyAPI's cloak system. +Antigravity requests preserve caller text byte-for-byte. OmniRoute does not insert zero-width characters into prompts or rename/inject tools to imitate an IDE client. ### `antigravityHeaderScrub.ts` @@ -129,13 +127,13 @@ The upstream enforcement is on **Google's side**, not anything OmniRoute can pre **Recommended posture:** -1. **Default to `ANTIGRAVITY_CREDITS=retry`** — overages are used only when free-tier returns 429, not on every request. This is the safer of the two non-zero modes. +1. Keep the default `ANTIGRAVITY_CREDITS=off` unless the operator explicitly accepts paid-credit and account-enforcement risk. `retry` sends the normal request first and injects credits at most once after an eligible quota 429; `always` injects credits on the first request. 2. **Spread load across providers via Auto-Combo** (`model: "auto"` or `kr/glm/etc`-combo) instead of saturating a single Antigravity account. 3. **Set per-connection RPM limits** in the Antigravity provider's edit page (Dashboard → Providers → Antigravity → connection → rate limit). 30–60 RPM is a defensible upper bound for sustained use. -4. **Use distinct upstream IPs** per Antigravity account when possible (residential proxies aimed at the same account from many users compounds the abuse signal). +4. **Use stable, operator-controlled upstream networking** and avoid sharing one account across unrelated users or workloads. 5. **If banned**: appeal via `support.google.com` → "Restore Workspace/Account access" with the exact `quota_exceeded` / `service disabled` response body Google sent. Restoration is not guaranteed. -This warning is also surfaced inline in the dashboard near the Antigravity provider edit screen when `ANTIGRAVITY_CREDITS` is set to `always` (or will be in v3.8.0; tracked separately). +The environment reference documents the account and spend implications of each credits mode. Touch points: diff --git a/open-sse/config/agyModels.ts b/open-sse/config/agyModels.ts index 919b97e957..7caad9d28c 100644 --- a/open-sse/config/agyModels.ts +++ b/open-sse/config/agyModels.ts @@ -2,20 +2,46 @@ // // These models are pinned from the live `:fetchAvailableModels` endpoint // (https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels) using a -// real `agy` consumer-OAuth token. The public catalog exposes the same clean Gemini -// 3.5 Flash tier names as the Antigravity IDE provider; the shared Antigravity executor -// maps those names to the legacy upstream IDs immediately before dispatch. +// real `agy` consumer-OAuth token. The public catalog exposes the upstream Gemini 3.6 +// and 3.5 Flash ids verbatim; the shared Antigravity executor dispatches them unchanged. // // The `agy` provider reuses the `antigravity` executor/translator (identical backend), -// but ships its OWN catalog so it can expose models the `antigravity` provider's static -// list omits — notably the Claude models (`claude-opus-4-6-thinking`, `claude-sonnet-4-6`), -// which `:fetchAvailableModels` reports as user-callable with quota even though the -// `antigravity` catalog comment assumes they 404. Tab-completion models +// but keeps its own catalog so the CLI and IDE model surfaces can evolve independently. +// Both currently expose the same callable Claude/Gemini/GPT set. Tab-completion models // (`tab_flash_lite_preview`, `tab_jump_flash_lite_preview`) are intentionally excluded — // they are not chat-callable. export const AGY_PUBLIC_MODELS = Object.freeze([ - // Claude (Antigravity backend) — the headline differentiator for this provider. + // Gemini 3.6 Flash tiers. The live endpoint selects High by default and advertises + // all three ids to both the IDE 2.1.1 and CLI 1.1.x clients. + { + id: "gemini-3.6-flash-high", + name: "Gemini 3.6 Flash (High)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.6-flash-medium", + name: "Gemini 3.6 Flash (Medium)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.6-flash-low", + name: "Gemini 3.6 Flash (Low)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + // Claude (Antigravity backend). { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 (Thinking)", @@ -34,9 +60,9 @@ export const AGY_PUBLIC_MODELS = Object.freeze([ supportsVision: true, toolCalling: true, }, - // Gemini 3.x + // Gemini 3.1 Pro { - id: "gemini-3.1-pro-high", + id: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)", contextLength: 1048576, maxOutputTokens: 65535, @@ -54,24 +80,16 @@ export const AGY_PUBLIC_MODELS = Object.freeze([ toolCalling: true, }, { - id: "gemini-pro-agent", - name: "Gemini 3.1 Pro (Agent)", + id: "gemini-3-flash-agent", + name: "Gemini 3.5 Flash (High)", contextLength: 1048576, - maxOutputTokens: 65535, + maxOutputTokens: 65536, supportsReasoning: true, supportsVision: true, toolCalling: true, }, { id: "gemini-3.5-flash-low", - name: "Gemini 3.5 Flash (Low)", - contextLength: 1048576, - maxOutputTokens: 65536, - supportsVision: true, - toolCalling: true, - }, - { - id: "gemini-3.5-flash-medium", name: "Gemini 3.5 Flash (Medium)", contextLength: 1048576, maxOutputTokens: 65536, @@ -80,8 +98,8 @@ export const AGY_PUBLIC_MODELS = Object.freeze([ toolCalling: true, }, { - id: "gemini-3.5-flash-high", - name: "Gemini 3.5 Flash (High)", + id: "gemini-3.5-flash-extra-low", + name: "Gemini 3.5 Flash (Low)", contextLength: 1048576, maxOutputTokens: 65536, supportsReasoning: true, @@ -95,15 +113,13 @@ export const AGY_PUBLIC_MODELS = Object.freeze([ maxOutputTokens: 65535, toolCalling: true, }, - { id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }, // Gemini 2.5 { - id: "gemini-2.5-pro", - name: "Gemini 2.5 Pro", + id: "gemini-2.5-flash-thinking", + name: "Gemini 2.5 Flash Thinking", contextLength: 1048576, maxOutputTokens: 65535, supportsReasoning: true, - supportsVision: true, toolCalling: true, }, { @@ -113,14 +129,6 @@ export const AGY_PUBLIC_MODELS = Object.freeze([ maxOutputTokens: 65535, toolCalling: true, }, - { - id: "gemini-2.5-flash-thinking", - name: "Gemini 2.5 Flash Thinking", - contextLength: 1048576, - maxOutputTokens: 65535, - supportsReasoning: true, - toolCalling: true, - }, { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite", diff --git a/open-sse/config/antigravityModelAliases.ts b/open-sse/config/antigravityModelAliases.ts index 3feebf495b..d1fd5eb8aa 100644 --- a/open-sse/config/antigravityModelAliases.ts +++ b/open-sse/config/antigravityModelAliases.ts @@ -1,18 +1,38 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ - // Claude (Antigravity backend). The `agy` provider already ships these from the live - // :fetchAvailableModels probe (see agyModels.ts) and discussion #3184 confirmed they - // are user-callable through the `antigravity` OAuth provider too — same backend. - // `antigravity/claude-opus-4-6-thinking` and `antigravity/claude-sonnet-4-6` both work. - // They are upstream IDs, so no alias remapping is required. + // Gemini 3.6 Flash tiers returned by the live model selector for both the IDE 2.1.1 + // and CLI 1.1.x client identities. High is the current defaultAgentModelId. { - id: "claude-sonnet-5", - name: "Claude Sonnet 5 (Thinking)", + id: "gemini-3.6-flash-high", + name: "Gemini 3.6 Flash (High)", contextLength: 1048576, maxOutputTokens: 65536, supportsReasoning: true, supportsVision: true, toolCalling: true, }, + { + id: "gemini-3.6-flash-medium", + name: "Gemini 3.6 Flash (Medium)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.6-flash-low", + name: "Gemini 3.6 Flash (Low)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + // Claude (Antigravity backend). The `agy` provider already ships these from the live + // :fetchAvailableModels probe (see agyModels.ts) and discussion #3184 confirmed they + // are user-callable through the `antigravity` OAuth provider too — same backend. + // `antigravity/claude-opus-4-6-thinking` and `antigravity/claude-sonnet-4-6` both work. + // They are upstream IDs, so no alias remapping is required. { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 (Thinking)", @@ -31,55 +51,11 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ supportsVision: true, toolCalling: true, }, - // Gemini 3.5 Flash tiers exposed by Antigravity 2.0.4's model selector. - // The user-facing names are Low / Medium / High, but fetchAvailableModels reports - // legacy upstream IDs for two of them: - // Low -> gemini-3.5-flash-extra-low (displayName: Gemini 3.5 Flash (Low)) - // Medium -> gemini-3.5-flash-low (displayName: Gemini 3.5 Flash (Medium)) - // High -> gemini-3-flash-agent (displayName: Gemini 3.5 Flash (High)) - // Keep the clean public IDs here and map them below for routing/quota. + // Gemini 3.1 Pro budget tiers. Live streamGenerateContent validation uses + // `gemini-pro-agent` for High; the separately advertised `gemini-3.1-pro-high` + // discovery slot currently returns HTTP 400 and is intentionally not public. { - id: "gemini-3.5-flash-low", - name: "Gemini 3.5 Flash (Low)", - contextLength: 1048576, - maxOutputTokens: 65536, - supportsReasoning: true, - supportsVision: true, - toolCalling: true, - }, - { - id: "gemini-3.5-flash-medium", - name: "Gemini 3.5 Flash (Medium)", - contextLength: 1048576, - maxOutputTokens: 65536, - supportsReasoning: true, - supportsVision: true, - toolCalling: true, - }, - { - id: "gemini-3.5-flash-high", - name: "Gemini 3.5 Flash (High)", - contextLength: 1048576, - maxOutputTokens: 65536, - supportsReasoning: true, - supportsVision: true, - toolCalling: true, - }, - { - id: "gemini-3-pro-preview", - name: "Gemini 3.1 Pro", - contextLength: 1048576, - maxOutputTokens: 65535, - supportsReasoning: true, - supportsVision: true, - toolCalling: true, - }, - // Gemini 3.1 Pro budget tiers — agy ships these and they route directly via the - // antigravity OAuth provider. The upstream ACCEPTS the suffixed ids verbatim (wire- - // confirmed via `agy --model gemini-3.1-pro-high`: 200 OK on /v1internal:streamGenerateContent). - // No alias needed; see #3696 (supersedes the #3229 premise). - { - id: "gemini-3.1-pro-high", + id: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)", contextLength: 1048576, maxOutputTokens: 65535, @@ -96,6 +72,38 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ supportsVision: true, toolCalling: true, }, + // Gemini 3.5 Flash tiers exposed by Antigravity's model selector. Public ids match + // fetchAvailableModels and are forwarded upstream unchanged: + // High -> gemini-3-flash-agent (displayName: Gemini 3.5 Flash (High)) + // Medium -> gemini-3.5-flash-low (displayName: Gemini 3.5 Flash (Medium)) + // Low -> gemini-3.5-flash-extra-low (displayName: Gemini 3.5 Flash (Low)) + { + id: "gemini-3-flash-agent", + name: "Gemini 3.5 Flash (High)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.5-flash-low", + name: "Gemini 3.5 Flash (Medium)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.5-flash-extra-low", + name: "Gemini 3.5 Flash (Low)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, { id: "gemini-3.1-flash-lite", name: "Gemini 3.1 Flash Lite", @@ -104,12 +112,10 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ toolCalling: true, }, { - id: "gemini-2.5-pro", - name: "Gemini 2.5 Pro", + id: "gemini-2.5-flash-thinking", + name: "Gemini 2.5 Flash Thinking", contextLength: 1048576, maxOutputTokens: 65535, - supportsReasoning: true, - supportsVision: true, toolCalling: true, }, { @@ -126,22 +132,6 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ maxOutputTokens: 65535, toolCalling: true, }, - { - id: "gemini-2.5-flash-thinking", - name: "Gemini 2.5 Flash Thinking", - contextLength: 1048576, - maxOutputTokens: 65535, - toolCalling: true, - }, - { - id: "gemini-pro-agent", - name: "Gemini 3.1 Pro (High)", - contextLength: 1048576, - maxOutputTokens: 65535, - supportsReasoning: true, - supportsVision: true, - toolCalling: true, - }, { id: "gpt-oss-120b-medium", name: "GPT-OSS 120B (Medium)", @@ -150,30 +140,11 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ supportsReasoning: true, toolCalling: true, }, - { id: "gemini-3-pro-image-preview", name: "Gemini 3 Pro Image" }, - { id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }, - { - id: "gemini-2.5-computer-use-preview-10-2025", - name: "Gemini 2.5 Computer Use Preview (10/2025)", - }, ]); export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({ - "gemini-3.5-flash-low": "gemini-3.5-flash-extra-low", - "gemini-3.5-flash-medium": "gemini-3.5-flash-low", - "gemini-3.5-flash-high": "gemini-3-flash-agent", - // Backward-compat: the retired flagship public id `gemini-3.5-flash-preview` - // (Antigravity 2.0's "Gemini 3.5 Flash") is kept as a HIDDEN alias so saved - // combos/configs keep routing — it maps to the reasoning-capable High tier - // (upstream `gemini-3-flash-agent`). It is NOT re-added to the public catalog. - "gemini-3.5-flash-preview": "gemini-3-flash-agent", - "gemini-3-pro-preview": "gemini-3.1-pro", - // gemini-3.1-pro-high and gemini-3.1-pro-low are NOT aliased here: wire capture - // (#3696) confirmed the upstream accepts the suffixed ids verbatim → pass through. - // (The earlier #3229 assumption — "upstream rejects -high/-low for gemini-3.x" — - // was refuted by the agy --log-file 200 OK evidence.) + // gemini-3.1-pro-low is not aliased: the upstream accepts it verbatim. "gemini-3-pro-image-preview": "gemini-3-pro-image", - "gemini-2.5-computer-use-preview-10-2025": "rev19-uic3-1p", // Legacy Claude display ids → current upstream ids. NOTE: an earlier comment here // assumed Claude was removed from Antigravity 2.0 and would 404; discussion #3184 // disproved that — the Antigravity OAuth backend still serves claude-opus-4-6-thinking @@ -187,33 +158,11 @@ export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({ type AntigravityModelAliasMap = Record; /** - * #3786 — Per-request upstream-id FALLBACK CHAINS for the Gemini 3.1 Pro family. - * - * On recent Antigravity versions `gemini-3.1-pro-high` started returning HTTP 400 - * ("Antigravity upstream error (400)") while `gemini-3.1-pro-low` still works. The upstream - * changed the accepted id format for the Pro-high tier and the live id cannot be determined - * from static analysis — the two actively-maintained competitor proxies DISAGREE: - * - AntigravityManager → `gemini-3.1-pro-high` - * - CLIProxyAPI → `gemini-pro-agent` (display: "Gemini 3.1 Pro (High)") - * - older form → `gemini-3-pro-high` - * - * Mirroring AntigravityManager's robust approach, the executor retries the next candidate id - * on a 400 until one succeeds (2xx) or the chain is exhausted. This is a REQUEST-TIME retry, - * NOT a change to the static `resolveAntigravityModelId` map — the #3696 pass-through - * invariant (suffixed ids reach the upstream verbatim on the FIRST attempt) is preserved. - * - * Each chain starts with its own key so the happy path (first id 200) incurs zero extra - * calls, and every candidate is listed at most once so the retry loop is bounded. + * Per-request upstream-id fallback chains for callable Gemini 3.1 Pro tiers. + * Each chain starts with its own key and every candidate is listed at most once. */ export const ANTIGRAVITY_PRO_FALLBACK_CHAINS: Readonly> = Object.freeze({ - "gemini-3.1-pro-high": Object.freeze([ - "gemini-3.1-pro-high", - "gemini-pro-agent", - "gemini-3-pro-high", - ]), - // pro-low currently works but is given a trivially-symmetric chain for resilience if the - // upstream renames it the same way it renamed pro-high. "gemini-3.1-pro-low": Object.freeze(["gemini-3.1-pro-low", "gemini-3-pro-low"]), }); @@ -228,11 +177,7 @@ export function getAntigravityModelFallbacks(modelId: string): readonly string[] } export const ANTIGRAVITY_REVERSE_MODEL_ALIASES: AntigravityModelAliasMap = Object.freeze({ - "gemini-3.5-flash-extra-low": "gemini-3.5-flash-low", - "gemini-3-flash-agent": "gemini-3.5-flash-high", - "gemini-3.1-pro": "gemini-3-pro-preview", "gemini-3-pro-image": "gemini-3-pro-image-preview", - "rev19-uic3-1p": "gemini-2.5-computer-use-preview-10-2025", }); const CLIENT_VISIBLE_MODEL_NAMES = Object.freeze( @@ -257,22 +202,6 @@ export function toClientAntigravityModelId(modelId: string): string { return ANTIGRAVITY_REVERSE_MODEL_ALIASES[modelId] || modelId; } -// Quota buckets reported by the Antigravity backend are keyed by UPSTREAM model ids — a -// DIFFERENT namespace from the public/client catalog. In that upstream quota namespace -// `gemini-3.5-flash-low` denotes the *Medium* tier's bucket (it is the upstream target of -// the `gemini-3.5-flash-medium` forward alias), even though the same literal is also a -// public "Low" client id. This remap therefore CANNOT be derived from -// ANTIGRAVITY_REVERSE_MODEL_ALIASES (which has no `gemini-3.5-flash-low` entry precisely -// because it is already a valid client id) — it encodes the upstream-bucket → client-tier -// chain explicitly. Keep it the inverse of the `-low/-medium/-high` rows in -// ANTIGRAVITY_MODEL_ALIASES above. (#3821-review LEDGER-5 — was duplicated as an inline -// if-ladder in open-sse/services/usage.ts.) -const ANTIGRAVITY_QUOTA_BUCKET_TO_CLIENT: AntigravityModelAliasMap = Object.freeze({ - "gemini-3.5-flash-extra-low": "gemini-3.5-flash-low", - "gemini-3.5-flash-low": "gemini-3.5-flash-medium", - "gemini-3-flash-agent": "gemini-3.5-flash-high", -}); - // Retired/hidden upstream preview buckets that must be dropped from client-facing usage. const ANTIGRAVITY_DROPPED_QUOTA_BUCKETS = new Set([ "gemini-3.5-flash-preview", @@ -280,17 +209,12 @@ const ANTIGRAVITY_DROPPED_QUOTA_BUCKETS = new Set([ ]); /** - * Map an UPSTREAM Antigravity quota-bucket model id to the client-visible tier id used in - * usage responses, or `null` if the bucket should be hidden from clients. Operates on the - * upstream quota namespace (see ANTIGRAVITY_QUOTA_BUCKET_TO_CLIENT) — do NOT pass client - * ids here. Single source of truth shared by the usage service and the provider-limits - * cache sanitizer. + * Keep Antigravity quota buckets in the upstream model-id namespace used by the public + * catalog, or return `null` when a retired preview bucket should be hidden from clients. */ export function toClientAntigravityQuotaModelId(modelId: string): string | null { if (!modelId) return null; if (ANTIGRAVITY_DROPPED_QUOTA_BUCKETS.has(modelId)) return null; - const tierClientId = ANTIGRAVITY_QUOTA_BUCKET_TO_CLIENT[modelId]; - if (tierClientId) return tierClientId; return toClientAntigravityModelId(modelId); } diff --git a/open-sse/config/antigravityUpstream.ts b/open-sse/config/antigravityUpstream.ts index 539690665f..1ed328d7f3 100644 --- a/open-sse/config/antigravityUpstream.ts +++ b/open-sse/config/antigravityUpstream.ts @@ -1,20 +1,28 @@ -export const ANTIGRAVITY_BASE_URLS = Object.freeze([ +export const ANTIGRAVITY_RUNTIME_BASE_URLS = Object.freeze([ "https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com", +]); + +export const ANTIGRAVITY_DISCOVERY_BASE_URLS = Object.freeze([ + ...ANTIGRAVITY_RUNTIME_BASE_URLS, "https://daily-cloudcode-pa.sandbox.googleapis.com", ]); +export const ANTIGRAVITY_BOOTSTRAP_BASE_URLS = Object.freeze([ + "https://cloudcode-pa.googleapis.com", +]); + const ANTIGRAVITY_MODELS_PATH = "/v1internal:models"; const ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels"; -function buildAntigravityUrls(path: string): string[] { - return ANTIGRAVITY_BASE_URLS.map((baseUrl) => `${baseUrl}${path}`); +function buildAntigravityDiscoveryUrls(path: string): string[] { + return ANTIGRAVITY_DISCOVERY_BASE_URLS.map((baseUrl) => `${baseUrl}${path}`); } export function getAntigravityModelsDiscoveryUrls(): string[] { - return buildAntigravityUrls(ANTIGRAVITY_MODELS_PATH); + return buildAntigravityDiscoveryUrls(ANTIGRAVITY_MODELS_PATH); } export function getAntigravityFetchAvailableModelsUrls(): string[] { - return buildAntigravityUrls(ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH); + return buildAntigravityDiscoveryUrls(ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH); } diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 3d0e693ae6..1a32c92864 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -16,7 +16,7 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts"; * rewrites file timestamps on every deploy, which would report a months-old * catalog as "updated today". Bump this whenever the entries below change. */ -export const FREE_CATALOG_CURATED_AT = "2026-07-20"; +export const FREE_CATALOG_CURATED_AT = "2026-07-22"; export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "agentrouter", modelId: "claude-opus-4-6", displayName: "Claude 4.6 Opus", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, @@ -25,16 +25,15 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "agentrouter", modelId: "deepseek-v3.2", displayName: "DeepSeek V3.2", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, { provider: "agy", modelId: "claude-opus-4-6-thinking", displayName: "Claude Opus 4.6 (Thinking)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, { provider: "agy", modelId: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6 (Thinking)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, - { provider: "agy", modelId: "gemini-3.1-pro-high", displayName: "Gemini 3.1 Pro (High)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, { provider: "agy", modelId: "gemini-3.1-pro-low", displayName: "Gemini 3.1 Pro (Low)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, - { provider: "agy", modelId: "gemini-pro-agent", displayName: "Gemini 3.1 Pro (Agent)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, - { provider: "agy", modelId: "gemini-3-flash-agent", displayName: "Gemini 3.5 Flash (Agent)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, - { provider: "agy", modelId: "gemini-3-flash", displayName: "Gemini 3 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, - { provider: "agy", modelId: "gemini-3.5-flash-low", displayName: "Gemini 3.5 Flash (Low)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, - { provider: "agy", modelId: "gemini-3.5-flash-extra-low", displayName: "Gemini 3.5 Flash (Extra Low)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, + { provider: "agy", modelId: "gemini-pro-agent", displayName: "Gemini 3.1 Pro (High)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, + { provider: "agy", modelId: "gemini-3.6-flash-high", displayName: "Gemini 3.6 Flash (High)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, + { provider: "agy", modelId: "gemini-3.6-flash-medium", displayName: "Gemini 3.6 Flash (Medium)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, + { provider: "agy", modelId: "gemini-3.6-flash-low", displayName: "Gemini 3.6 Flash (Low)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, + { provider: "agy", modelId: "gemini-3-flash-agent", displayName: "Gemini 3.5 Flash (High)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, + { provider: "agy", modelId: "gemini-3.5-flash-low", displayName: "Gemini 3.5 Flash (Medium)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, + { provider: "agy", modelId: "gemini-3.5-flash-extra-low", displayName: "Gemini 3.5 Flash (Low)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, { provider: "agy", modelId: "gemini-3.1-flash-lite", displayName: "Gemini 3.1 Flash Lite", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, - { provider: "agy", modelId: "gemini-3.1-flash-image", displayName: "Gemini 3.1 Flash Image", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, - { provider: "agy", modelId: "gemini-2.5-pro", displayName: "Gemini 2.5 Pro", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, { provider: "agy", modelId: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, { provider: "agy", modelId: "gemini-2.5-flash-thinking", displayName: "Gemini 2.5 Flash Thinking", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, { provider: "agy", modelId: "gemini-2.5-flash-lite", displayName: "Gemini 2.5 Flash Lite", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, diff --git a/open-sse/config/providerHeaderProfiles.ts b/open-sse/config/providerHeaderProfiles.ts index 47d33a84ad..5f038e4c21 100644 --- a/open-sse/config/providerHeaderProfiles.ts +++ b/open-sse/config/providerHeaderProfiles.ts @@ -1,4 +1,5 @@ -import { antigravityUserAgent } from "../services/antigravityHeaders.ts"; +import { getAntigravityContentHeaders } from "../services/antigravityHeaders.ts"; +import type { AntigravityClientProfile } from "@/shared/constants/antigravityClientProfile"; export const GITHUB_COPILOT_API_VERSION = "2026-06-01"; export const GITHUB_COPILOT_EDITOR_VERSION = "vscode/1.126.0"; @@ -125,14 +126,14 @@ export function getQoderDashscopeCompatHeaders(): Record { }; } -export function getAntigravityUserAgent(): string { - return antigravityUserAgent(); +export function getAntigravityUserAgent(profile: AntigravityClientProfile = "ide"): string { + return getAntigravityContentHeaders(profile)["User-Agent"]; } -export function getAntigravityProviderHeaders(): Record { - return { - "User-Agent": getAntigravityUserAgent(), - }; +export function getAntigravityProviderHeaders( + profile: AntigravityClientProfile = "ide" +): Record { + return getAntigravityContentHeaders(profile); } export function getKiroServiceHeaders( diff --git a/open-sse/config/providers/registry/agy/index.ts b/open-sse/config/providers/registry/agy/index.ts index da91275a38..6aab7fe3d9 100644 --- a/open-sse/config/providers/registry/agy/index.ts +++ b/open-sse/config/providers/registry/agy/index.ts @@ -1,7 +1,7 @@ import type { RegistryEntry } from "../../shared.ts"; import { buildAntigravityUrl, - ANTIGRAVITY_BASE_URLS, + ANTIGRAVITY_RUNTIME_BASE_URLS, AGY_PUBLIC_MODELS, getAntigravityProviderHeaders, resolvePublicCred, @@ -12,11 +12,11 @@ export const agyProvider: RegistryEntry = { alias: "agy", format: "antigravity", executor: "antigravity", - baseUrls: [...ANTIGRAVITY_BASE_URLS], + baseUrls: [...ANTIGRAVITY_RUNTIME_BASE_URLS], urlBuilder: buildAntigravityUrl, authType: "oauth", authHeader: "bearer", - headers: getAntigravityProviderHeaders(), + headers: getAntigravityProviderHeaders("cli"), oauth: { clientIdEnv: "ANTIGRAVITY_OAUTH_CLIENT_ID", clientIdDefault: resolvePublicCred("antigravity_id"), diff --git a/open-sse/config/providers/registry/antigravity/index.ts b/open-sse/config/providers/registry/antigravity/index.ts index 48899ca75f..74addaf815 100644 --- a/open-sse/config/providers/registry/antigravity/index.ts +++ b/open-sse/config/providers/registry/antigravity/index.ts @@ -1,7 +1,7 @@ import type { RegistryEntry } from "../../shared.ts"; import { buildAntigravityUrl, - ANTIGRAVITY_BASE_URLS, + ANTIGRAVITY_RUNTIME_BASE_URLS, ANTIGRAVITY_PUBLIC_MODELS, getAntigravityProviderHeaders, resolvePublicCred, @@ -12,7 +12,7 @@ export const antigravityProvider: RegistryEntry = { alias: undefined, format: "antigravity", executor: "antigravity", - baseUrls: [...ANTIGRAVITY_BASE_URLS], + baseUrls: [...ANTIGRAVITY_RUNTIME_BASE_URLS], urlBuilder: buildAntigravityUrl, authType: "oauth", authHeader: "bearer", diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index b5d7adbad3..1ca2b7601a 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -6,7 +6,7 @@ * is auto-generated from this registry. */ -import { ANTIGRAVITY_BASE_URLS } from "../antigravityUpstream.ts"; +import { ANTIGRAVITY_RUNTIME_BASE_URLS } from "../antigravityUpstream.ts"; import { ANTIGRAVITY_PUBLIC_MODELS } from "../antigravityModelAliases.ts"; import { AGY_PUBLIC_MODELS } from "../agyModels.ts"; import { @@ -653,7 +653,7 @@ export function mapStainlessArch() { // ── Registry ────────────────────────────────────────────────────────────── export { - ANTIGRAVITY_BASE_URLS, + ANTIGRAVITY_RUNTIME_BASE_URLS, ANTIGRAVITY_PUBLIC_MODELS, AGY_PUBLIC_MODELS, ANTHROPIC_BETA_API_KEY, diff --git a/open-sse/config/toolCloaking.ts b/open-sse/config/toolCloaking.ts index 0dab4fc9ac..c130b89815 100644 --- a/open-sse/config/toolCloaking.ts +++ b/open-sse/config/toolCloaking.ts @@ -1,64 +1,16 @@ type JsonRecord = Record; -export const AG_TOOL_SUFFIX = "_ide"; - -const AG_DEFAULT_TOOL_NAMES = [ - "browser_subagent", - "command_status", - "find_by_name", - "generate_image", - "grep_search", - "list_dir", - "list_resources", - "multi_replace_file_content", - "notify_user", - "read_resource", - "read_terminal", - "read_url_content", - "replace_file_content", - "run_command", - "search_web", - "send_command_input", - "task_boundary", - "view_content_chunk", - "view_file", - "write_to_file", -] as const; - -const AG_DECOY_TOOL_NAMES = [ - ...AG_DEFAULT_TOOL_NAMES, - "mcp_sequential_thinking_sequentialthinking", -] as const; - -export const AG_DEFAULT_TOOLS = new Set(AG_DEFAULT_TOOL_NAMES); - -export const AG_DECOY_TOOLS = AG_DECOY_TOOL_NAMES.map((name) => - Object.freeze({ - name, - description: "This tool is currently unavailable.", - parameters: { - type: "OBJECT", - properties: {}, - required: [], - }, - }) -); - function asRecord(value: unknown): JsonRecord | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; } -function toToolName(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - /** * Recursively strip `enumDescriptions` from a JSON schema. * * VSCode Copilot emits `enumDescriptions` inside tool parameter schemas, but the * Antigravity API rejects any request carrying that field with HTTP 400. Walk the - * schema's `properties` and `items` so the field is removed at every nesting level - * before the declaration is sent upstream. + * entire value tree so the field is removed from composition keywords (`anyOf`, + * `allOf`, `oneOf`), `$defs`, `additionalProperties`, and future schema shapes too. */ export function stripEnumDescriptions(schema: unknown): unknown { if (!schema || typeof schema !== "object") return schema; @@ -67,199 +19,67 @@ export function stripEnumDescriptions(schema: unknown): unknown { return schema.map((entry) => stripEnumDescriptions(entry)); } - const result: JsonRecord = { ...(schema as JsonRecord) }; - delete result.enumDescriptions; - - const properties = asRecord(result.properties); - if (properties) { - const nextProperties: JsonRecord = {}; - for (const key of Object.keys(properties)) { - nextProperties[key] = stripEnumDescriptions(properties[key]); + const result: JsonRecord = {}; + for (const [key, value] of Object.entries(schema as JsonRecord)) { + if (key !== "enumDescriptions") { + result[key] = stripEnumDescriptions(value); } - result.properties = nextProperties; - } - - if (result.items !== undefined) { - result.items = stripEnumDescriptions(result.items); } return result; } -export function shouldCloakAntigravityTool(toolName: string): boolean { - return ( - toolName.length > 0 && !AG_DEFAULT_TOOLS.has(toolName) && !toolName.endsWith(AG_TOOL_SUFFIX) - ); -} - -export function getCloakedAntigravityToolName(toolName: string): string { - return shouldCloakAntigravityTool(toolName) ? `${toolName}${AG_TOOL_SUFFIX}` : toolName; -} - -export function cloakAntigravityToolPayload( - body: T -): { - body: T; - toolNameMap: Map | null; -} { +/** + * Sanitize Antigravity tool schemas without changing client-declared tool identity. + * + * Declaration order, declaration names, historical function calls, function responses, + * tool configuration, and non-function tools are preserved. Only schema fields rejected + * by Cloud Code are removed. + */ +export function sanitizeAntigravityToolPayload(body: T): T { const request = asRecord(body.request); - if (!request) { - return { body, toolNameMap: null }; + if (!request || !Array.isArray(request.tools)) { + return body; } - const existingToolNameMap = - body._toolNameMap instanceof Map ? (body._toolNameMap as Map) : null; - const toolNameMap = existingToolNameMap - ? new Map(existingToolNameMap) - : new Map(); let changed = false; - - const nextRequest: JsonRecord = { - ...request, - }; - - if (Array.isArray(request.tools)) { - const preservedTools: JsonRecord[] = []; - const cloakedDeclarations: JsonRecord[] = []; - - for (const toolValue of request.tools) { - const tool = asRecord(toolValue); - if (!tool || !Array.isArray(tool.functionDeclarations)) { - preservedTools.push(toolValue as JsonRecord); - continue; - } - - for (const declarationValue of tool.functionDeclarations) { - const declaration = asRecord(declarationValue); - if (!declaration) continue; - - // VSCode Copilot sends `enumDescriptions` in tool parameter schemas, but the - // Antigravity API rejects requests carrying that field with HTTP 400. Strip it - // recursively before the declaration is forwarded upstream. - const stripped: JsonRecord = - declaration.parameters !== undefined - ? { ...declaration, parameters: stripEnumDescriptions(declaration.parameters) } - : declaration; - if (stripped !== declaration) { - changed = true; - } - - const rawName = toToolName(stripped.name); - if (!rawName) { - cloakedDeclarations.push({ ...stripped }); - continue; - } - - const cloakedName = getCloakedAntigravityToolName(rawName); - if (cloakedName !== rawName) { - changed = true; - toolNameMap.set(cloakedName, toolNameMap.get(rawName) ?? rawName); - } - - cloakedDeclarations.push({ - ...stripped, - name: cloakedName, - }); - } + const tools = request.tools.map((toolValue) => { + const tool = asRecord(toolValue); + if (!tool || !Array.isArray(tool.functionDeclarations)) { + return toolValue; } - if (cloakedDeclarations.length > 0) { - const declaredNames = new Set( - cloakedDeclarations - .map((declaration) => toToolName(declaration.name)) - .filter((name) => name.length > 0) - ); - const decoys = AG_DECOY_TOOLS.filter((declaration) => !declaredNames.has(declaration.name)); - nextRequest.tools = [ - ...preservedTools, - { functionDeclarations: [...cloakedDeclarations, ...decoys] }, - ]; + let declarationsChanged = false; + const functionDeclarations = tool.functionDeclarations.map((declarationValue) => { + const declaration = asRecord(declarationValue); + if (!declaration || declaration.parameters === undefined) { + return declarationValue; + } - // The decoy tools mimic real Antigravity IDE built-in agent tools (search_web, - // browser_subagent, read_url_content, generate_image), whose names match the - // server-side "Built-in tools" categories Gemini 3's Cloud Code backend - // recognizes. A genuine Antigravity client always pairs those declarations with - // this opt-in flag; without it, mixing them with custom function declarations - // gets rejected upstream (#6914). - const existingToolConfig = asRecord(nextRequest.toolConfig) ?? {}; - nextRequest.toolConfig = { - ...existingToolConfig, - includeServerSideToolInvocations: true, - }; - changed = true; - } - } - - if (Array.isArray(request.contents)) { - let contentsChanged = false; - const nextContents = request.contents.map((contentValue) => { - const content = asRecord(contentValue); - if (!content || !Array.isArray(content.parts)) return contentValue; - - let partChanged = false; - const nextParts = content.parts.map((partValue) => { - const part = asRecord(partValue); - if (!part) return partValue; - - const nextPart: JsonRecord = { ...part }; - - const functionCall = asRecord(part.functionCall); - if (functionCall) { - const rawName = toToolName(functionCall.name); - const cloakedName = getCloakedAntigravityToolName(rawName); - if (cloakedName !== rawName) { - nextPart.functionCall = { - ...functionCall, - name: cloakedName, - }; - toolNameMap.set(cloakedName, toolNameMap.get(rawName) ?? rawName); - partChanged = true; - } - } - - const functionResponse = asRecord(part.functionResponse); - if (functionResponse) { - const rawName = toToolName(functionResponse.name); - const cloakedName = getCloakedAntigravityToolName(rawName); - if (cloakedName !== rawName) { - nextPart.functionResponse = { - ...functionResponse, - name: cloakedName, - }; - toolNameMap.set(cloakedName, toolNameMap.get(rawName) ?? rawName); - partChanged = true; - } - } - - return partChanged ? nextPart : partValue; - }); - - if (!partChanged) return contentValue; - contentsChanged = true; + declarationsChanged = true; return { - ...content, - parts: nextParts, + ...declaration, + parameters: stripEnumDescriptions(declaration.parameters), }; }); - if (contentsChanged) { - nextRequest.contents = nextContents; - changed = true; + if (!declarationsChanged) { + return toolValue; } - } + + changed = true; + return { ...tool, functionDeclarations }; + }); if (!changed) { - return { - body, - toolNameMap: toolNameMap.size > 0 ? toolNameMap : null, - }; + return body; } return { - body: { - ...body, - request: nextRequest, + ...body, + request: { + ...request, + tools, }, - toolNameMap: toolNameMap.size > 0 ? toolNameMap : null, }; } diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index e56ea92164..26e2c69edf 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -6,16 +6,11 @@ import { type ExecutorLog, type ProviderCredentials, } from "./base.ts"; -import { - PROVIDERS, - OAUTH_ENDPOINTS, - HTTP_STATUS, - FETCH_TIMEOUT_MS, -} from "../config/constants.ts"; +import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderScrub.ts"; import { - antigravityNativeOAuthUserAgent, - antigravityUserAgent, + getAntigravityContentHeaders, + getAntigravityOAuthUserAgent, } from "../services/antigravityHeaders.ts"; import { classify429, decide429, type Decision } from "../services/antigravity429Engine.ts"; import { @@ -27,8 +22,6 @@ import { import { persistCreditBalance, getAllPersistedCreditBalances } from "@/lib/db/creditBalance"; import { setConnectionRateLimitUntil } from "@/lib/db/providers"; import { getMitmAlias } from "@/lib/db/models"; -import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts"; -import { resolveAntigravityVersion } from "../services/antigravityVersion.ts"; import { ensureAntigravityProjectAssigned } from "../services/antigravityProjectBootstrap.ts"; import { resolveAntigravityModelId, @@ -39,7 +32,6 @@ import { stripCloudCodeThinkingConfig, } from "../services/cloudCodeThinking.ts"; import { buildGeminiTools } from "../translator/helpers/geminiToolsSanitizer.ts"; -import { DEFAULT_SAFETY_SETTINGS } from "../translator/helpers/geminiHelper.ts"; import { type AntigravityCollectedStream, processAntigravitySSEText, @@ -60,12 +52,17 @@ import { buildFinalAntigravityResult, buildAntigravity429ErrorMessage, markCreditsExhausted, + isAbortError, type SafeAntigravityLog, } from "./antigravity/executeAttempt.ts"; import { handleAntigravityFallbackChainError, handleAntigravityFallback400, } from "./antigravity/proFallbackChain.ts"; +import { + getAntigravityClientProfile, + resolveAntigravityClientVersion, +} from "../services/antigravityClientProfile.ts"; import { generateAntigravityRequestId, getAntigravityEnvelopeUserAgent, @@ -132,7 +129,7 @@ type AntigravityChunkContent = Record & { type AntigravityRequestEnvelope = Record & { project: string; model?: string; - userAgent: "antigravity" | "jetski"; + userAgent: "antigravity"; requestType: "agent" | "image_gen"; requestId: string; request: Record; @@ -287,7 +284,7 @@ async function cleanModelName(model: string, modelIdOverride?: string): Promise< * Agent mode regularly requests 32K–65K output tokens, which the Antigravity * backend rejects with HTTP 400 "Invalid Argument". 16384 matches the * upstream-accepted ceiling confirmed via successful 200 OK runs with - * claude-sonnet-4-6 and gemini-3.1-pro-high across both Ask and Agent modes. + * claude-sonnet-4-6 and gemini-pro-agent across both Ask and Agent modes. */ export const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 16384; @@ -341,9 +338,10 @@ function asRecord(value: unknown): Record | null { : null; } -function getAntigravitySafetySettings(safetySettings: unknown): unknown[] { - const source = Array.isArray(safetySettings) ? safetySettings : DEFAULT_SAFETY_SETTINGS; - return source.filter((setting) => { +function getAntigravitySafetySettings(safetySettings: unknown): unknown[] | undefined { + if (!Array.isArray(safetySettings)) return undefined; + + return safetySettings.filter((setting) => { const category = asRecord(setting)?.category; return typeof category !== "string" || !ANTIGRAVITY_UNSUPPORTED_SAFETY_CATEGORIES.has(category); }); @@ -369,18 +367,7 @@ function sanitizeAntigravityGeminiRequest( const geminiTools = buildGeminiTools(request.tools); if (geminiTools) { clean.tools = geminiTools; - // #6914: Preserve includeServerSideToolInvocations from the raw request's - // toolConfig when present (set by transformRequest when tools exist). The - // sanitize whitelist would otherwise rebuild toolConfig without it. - const rawToolConfig = asRecord(request.toolConfig); - const rawFnConfig = asRecord(rawToolConfig?.functionCallingConfig); - const includeServerSide = rawFnConfig?.includeServerSideToolInvocations === true; - clean.toolConfig = { - functionCallingConfig: { - mode: "VALIDATED", - ...(includeServerSide ? { includeServerSideToolInvocations: true } : {}), - }, - }; + clean.toolConfig = { functionCallingConfig: { mode: "VALIDATED" } }; } else if (asRecord(request.toolConfig)) { clean.toolConfig = request.toolConfig; } @@ -389,10 +376,8 @@ function sanitizeAntigravityGeminiRequest( clean.sessionId = request.sessionId; } - // #5003: preserve safetySettings through the Claude-path whitelist so the all-OFF - // default (or a caller-supplied value) actually reaches Google Cloud Code. Without - // this the field is dropped and Google applies its own safety defaults that - // false-flag benign technical prompts as `prohibited_content`. + // Preserve only caller-supplied safetySettings through the Claude-path whitelist. + // Missing settings stay absent so OmniRoute does not silently weaken upstream safety. if (Array.isArray(request.safetySettings)) { clean.safetySettings = request.safetySettings; } @@ -440,6 +425,8 @@ function stripTrailingAntigravityAssistantTurn( // Test-only export so the unit suite can exercise the strip logic directly. export const __test_stripTrailingAntigravityAssistantTurn = stripTrailingAntigravityAssistantTurn; +type AntigravityCreditsRetryState = { attempted: boolean }; + /** Base per-url-index attempt context, before the request has been sent. */ type AntigravityAttemptContext = { url: string; @@ -448,13 +435,13 @@ type AntigravityAttemptContext = { * credits-retry re-serializes from these, NOT from `finalHeaders` (already fingerprinted). */ headers: Record; transformedBody: Record; - requestToolNameMap: Map | null; credentials: AntigravityCredentials; stream: boolean; signal: AbortSignal | null | undefined; log: SafeAntigravityLog; accountId: string; creditsMode: ReturnType; + creditsRetryState: AntigravityCreditsRetryState; urlIndex: number; retryAttemptsByUrl: Record; fallbackCount: number; @@ -501,12 +488,10 @@ export class AntigravityExecutor extends BaseExecutor { } buildHeaders(credentials: AntigravityCredentials, _stream = true): Record { + const clientProfile = getAntigravityClientProfile(credentials); const raw = { - "Content-Type": "application/json", - Authorization: `Bearer ${credentials.accessToken}`, - "User-Agent": antigravityUserAgent(), + ...getAntigravityContentHeaders(clientProfile, credentials.accessToken), Accept: "text/event-stream", - "X-OmniRoute-Source": "omniroute", }; // Scrub proxy/fingerprint headers that reveal non-native traffic return scrubProxyAndFingerprintHeaders(raw); @@ -517,7 +502,8 @@ export class AntigravityExecutor extends BaseExecutor { body: unknown, _stream: boolean, credentials: AntigravityCredentials, - modelIdOverride?: string + modelIdOverride?: string, + signal?: AbortSignal ): Promise { // Project ID resolution: prefer OAuth-stored projectId over incoming body.project // to avoid stale/wrong client-side values causing 404/403 from Cloud Code endpoints. @@ -549,7 +535,12 @@ export class AntigravityExecutor extends BaseExecutor { // returned empty/transiently failed). Mirror the Cloud Code bootstrap to recover it // here — the helper memoizes per access-token, so this is a one-time round-trip. if (!projectId && credentials?.accessToken) { - const discovered = await ensureAntigravityProjectAssigned(credentials.accessToken); + const discovered = await ensureAntigravityProjectAssigned( + credentials.accessToken, + fetch, + getAntigravityClientProfile(credentials), + signal + ); if (discovered) projectId = discovered; } @@ -643,6 +634,7 @@ export class AntigravityExecutor extends BaseExecutor { } } + const safetySettings = getAntigravitySafetySettings(normalizedRequest?.safetySettings); const rawTransformedRequest = { ...normalizedRequest, ...(contents.length > 0 && { contents }), @@ -650,13 +642,10 @@ export class AntigravityExecutor extends BaseExecutor { credentials, typeof normalizedRequest?.sessionId === "string" ? normalizedRequest.sessionId : undefined ), - // #5003: send explicit all-OFF safety entries that Cloud Code accepts. Omitting the - // field lets Cloud Code apply server-side defaults that false-flag benign technical - // prompts as `prohibited_content`. - safetySettings: getAntigravitySafetySettings(normalizedRequest?.safetySettings), + ...(safetySettings !== undefined && { safetySettings }), toolConfig: Array.isArray(normalizedRequest?.tools) && normalizedRequest.tools.length > 0 - ? { functionCallingConfig: { mode: "VALIDATED", includeServerSideToolInvocations: true } } + ? { functionCallingConfig: { mode: "VALIDATED" } } : normalizedRequest?.toolConfig, }; @@ -666,20 +655,6 @@ export class AntigravityExecutor extends BaseExecutor { ) : rawTransformedRequest; - // 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); - } - } - } - } - } - applyAntigravityGenerationDefaults(transformedRequest); const { @@ -704,6 +679,7 @@ export class AntigravityExecutor extends BaseExecutor { reasoning: _reasoning, enable_thinking: _enableThinking, thinking_budget: _thinkingBudget, + enabledCreditTypes: _enabledCreditTypes, ...passthroughFields } = normalizedBody; @@ -718,10 +694,6 @@ export class AntigravityExecutor extends BaseExecutor { ...passthroughFields, }; - if (requestType === "agent" && envelope.enabledCreditTypes === undefined) { - envelope.enabledCreditTypes = ["GOOGLE_ONE_AI"]; - } - return envelope; } @@ -746,7 +718,7 @@ export class AntigravityExecutor extends BaseExecutor { headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", - "User-Agent": antigravityNativeOAuthUserAgent(), + "User-Agent": getAntigravityOAuthUserAgent(getAntigravityClientProfile(credentials)), }, body: new URLSearchParams(bodyParams), }); @@ -1015,7 +987,7 @@ export class AntigravityExecutor extends BaseExecutor { * exactly the same single call as before (zero extra upstream requests). */ async execute(input: ExecuteInput) { - await resolveAntigravityVersion(); + await resolveAntigravityClientVersion(getAntigravityClientProfile(input.credentials)); // Look up the chain by the NORMALLY-resolved upstream id (honours MITM/static aliases). // If a MITM alias remapped the id away from a known Pro tier, no chain applies → fast path. @@ -1088,7 +1060,7 @@ export class AntigravityExecutor extends BaseExecutor { { model, body, stream, credentials, signal, log, upstreamExtraHeaders }: ExecuteInput, modelIdOverride?: string ) { - await resolveAntigravityVersion(); + await resolveAntigravityClientVersion(getAntigravityClientProfile(credentials)); const fallbackCount = this.getFallbackCount(); const l = toSafeAntigravityLog(log); let lastError = null; @@ -1111,6 +1083,7 @@ export class AntigravityExecutor extends BaseExecutor { // preflight normal call is skipped entirely. const creditsMode = getCreditsMode(); const useCreditsFirst = shouldUseCreditsFirst(credentials?.accessToken || "", creditsMode); + const creditsRetryState: AntigravityCreditsRetryState = { attempted: false }; for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, upstreamStream, urlIndex); @@ -1121,18 +1094,15 @@ export class AntigravityExecutor extends BaseExecutor { body, upstreamStream, credentials, - modelIdOverride + modelIdOverride, + signal ?? undefined ); if (transformed instanceof Response) { return { response: transformed, url, headers, transformedBody: body }; } - const { transformedBody, requestToolNameMap } = finalizeAntigravityRequestBody( - transformed, - useCreditsFirst, - l - ); + const transformedBody = finalizeAntigravityRequestBody(transformed, useCreditsFirst, l); // Initialize retry counter for this URL if (!retryAttemptsByUrl[urlIndex]) { @@ -1145,13 +1115,13 @@ export class AntigravityExecutor extends BaseExecutor { model, headers, transformedBody, - requestToolNameMap, credentials, stream, signal, log: l, accountId, creditsMode, + creditsRetryState, urlIndex, retryAttemptsByUrl, fallbackCount, @@ -1162,6 +1132,9 @@ export class AntigravityExecutor extends BaseExecutor { if (outcome.sameUrl) urlIndex--; continue; } catch (error) { + if (signal?.aborted || isAbortError(error)) { + throw signal?.reason ?? error; + } lastError = error; l.error( "TELEMETRY", @@ -1193,7 +1166,6 @@ export class AntigravityExecutor extends BaseExecutor { model, headers, transformedBody, - requestToolNameMap, credentials, stream, signal, @@ -1253,7 +1225,6 @@ export class AntigravityExecutor extends BaseExecutor { url, finalHeaders, transformedBody, - requestToolNameMap, log ); if (embedded) return { action: "return", result: embedded }; @@ -1265,7 +1236,6 @@ export class AntigravityExecutor extends BaseExecutor { url, finalHeaders, transformedBody, - requestToolNameMap, accountId, signal, log @@ -1291,7 +1261,6 @@ export class AntigravityExecutor extends BaseExecutor { url: string, finalHeaders: Record, transformedBody: Record, - requestToolNameMap: Map | null, accountId: string, signal: AbortSignal | null | undefined, log: SafeAntigravityLog @@ -1314,7 +1283,6 @@ export class AntigravityExecutor extends BaseExecutor { url, finalHeaders, transformedBody, - requestToolNameMap, accountId, signal, updateAntigravityRemainingCredits @@ -1413,13 +1381,13 @@ export class AntigravityExecutor extends BaseExecutor { url, headers, transformedBody, - requestToolNameMap, credentials, stream, signal, log, accountId, creditsMode, + creditsRetryState, } = ctx; try { @@ -1439,12 +1407,19 @@ export class AntigravityExecutor extends BaseExecutor { const retryMs = decision.retryAfterMs; log.debug("AG_429", `Category: ${category}, Decision: ${decision.kind} — ${decision.reason}`); - if (decision.kind === "full_quota_exhausted" && retryMs) { - markConnectionQuotaExhausted(accountId, retryMs); - } - const creditsAlreadyInjected = (transformedBody as { enabledCreditTypes?: unknown }).enabledCreditTypes != null; + const creditsRetryEligible = + category === "quota_exhausted" && + !creditsAlreadyInjected && + !creditsRetryState.attempted && + shouldRetryWithCredits(credentials?.accessToken || "", creditsMode); + + // Retry mode gets one credits attempt before the account cooldown is persisted. + // All other full-quota paths fail closed immediately. + if (decision.kind === "full_quota_exhausted" && retryMs && !creditsRetryEligible) { + markConnectionQuotaExhausted(accountId, retryMs); + } if (category === "quota_exhausted" && creditsAlreadyInjected) { handleCreditsFailure(credentials?.accessToken || ""); @@ -1452,18 +1427,13 @@ export class AntigravityExecutor extends BaseExecutor { markCreditsExhausted(accountId); } - if ( - category === "quota_exhausted" && - decision.kind !== "full_quota_exhausted" && - !creditsAlreadyInjected && - shouldRetryWithCredits(credentials?.accessToken || "", creditsMode !== "off") - ) { + if (creditsRetryEligible) { + creditsRetryState.attempted = true; const creditsResult = await tryCreditsRetry( this.provider, url, headers, transformedBody, - requestToolNameMap, credentials, stream, signal, @@ -1472,10 +1442,14 @@ export class AntigravityExecutor extends BaseExecutor { updateAntigravityRemainingCredits ); if (creditsResult) return { kind: "return", result: creditsResult }; + if (retryMs) markConnectionQuotaExhausted(accountId, retryMs); } return { kind: "resolved", retryMs }; - } catch { + } catch (error) { + if (signal?.aborted || isAbortError(error)) { + throw signal?.reason ?? error; + } // Ignore parse errors, will fall back to exponential backoff return { kind: "resolved", retryMs: null }; } diff --git a/open-sse/executors/antigravity/executeAttempt.ts b/open-sse/executors/antigravity/executeAttempt.ts index 566e651713..420c5ece48 100644 --- a/open-sse/executors/antigravity/executeAttempt.ts +++ b/open-sse/executors/antigravity/executeAttempt.ts @@ -14,7 +14,7 @@ import { ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE, } from "../../config/constants.ts"; import { injectCreditsField, handleCreditsFailure } from "../../services/antigravityCredits.ts"; -import { cloakAntigravityToolPayload } from "../../config/toolCloaking.ts"; +import { sanitizeAntigravityToolPayload } from "../../config/toolCloaking.ts"; import { applyAntigravityClientProfileHeaders, removeHeaderCaseInsensitive, @@ -105,6 +105,13 @@ function isAntigravityPreResponseTimeout(error: unknown): boolean { return getAbortErrorCode(error) === ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE; } +export function isAbortError(error: unknown): boolean { + return ( + (error instanceof DOMException && error.name === "AbortError") || + (error instanceof Error && error.name === "AbortError") + ); +} + /** * `fetch()` wrapper that aborts if the upstream never returns response headers * within `timeoutMs` (default STREAM_READINESS_TIMEOUT_MS) — distinct from the @@ -176,9 +183,7 @@ export function toSafeAntigravityLog(log: ExecutorLog | null | undefined): SafeA /** Flatten a 429/503 error JSON body (message + `error.details[].reason`) into one string. */ export function buildAntigravity429ErrorMessage(errorJson: unknown): string { const obj = errorJson as - | { error?: { message?: unknown; details?: unknown }; message?: unknown } - | null - | undefined; + { error?: { message?: unknown; details?: unknown }; message?: unknown } | null | undefined; let errorMessage = String(obj?.error?.message || obj?.message || ""); const details = obj?.error?.details; if (Array.isArray(details)) { @@ -210,11 +215,36 @@ function cloneAntigravityRequestBody(body: unknown): unknown { return body; } + let clone: unknown; try { - return structuredClone(body); + clone = structuredClone(body); } catch { - return JSON.parse(JSON.stringify(body)); + clone = JSON.parse(JSON.stringify(body)); } + + if (clone && typeof clone === "object") { + delete (clone as Record)._toolNameMap; + } + return clone; +} + +function getToolNameMap(body: Record): Map | null { + return body._toolNameMap instanceof Map ? body._toolNameMap : null; +} + +function attachToolNameMap( + body: Record, + toolNameMap: Map | null +): Record { + if (toolNameMap) { + Object.defineProperty(body, "_toolNameMap", { + value: toolNameMap, + configurable: true, + enumerable: false, + writable: true, + }); + } + return body; } function serializeAntigravityRequest( @@ -235,48 +265,24 @@ function getRequestTargetModel(body: Record): string { return typeof target === "string" && target.length > 0 ? target : "unknown"; } -function attachToolNameMap(payload: T, toolNameMap: Map | null): T { - if (!toolNameMap?.size || !payload || typeof payload !== "object") { - return payload; - } - - const copy = Array.isArray(payload) ? ([...payload] as T) : ({ ...(payload as object) } as T); - Object.defineProperty(copy, "_toolNameMap", { - value: toolNameMap, - enumerable: false, - configurable: true, - writable: true, - }); - return copy; -} - -/** Cloak the tool-name payload, then apply credits-first injection, for one attempt. */ +/** Sanitize unsupported schema metadata, then apply credits-first injection for one attempt. */ export function finalizeAntigravityRequestBody( transformed: Record, useCreditsFirst: boolean, log: SafeAntigravityLog -): { - transformedBody: Record; - requestToolNameMap: Map | null; -} { - let transformedBody: Record = transformed; - let requestToolNameMap: Map | null = null; - - if (transformedBody && typeof transformedBody === "object") { - const cloaked = cloakAntigravityToolPayload(transformedBody); - transformedBody = cloaked.body; - requestToolNameMap = cloaked.toolNameMap; - } +): Record { + const toolNameMap = getToolNameMap(transformed); + let transformedBody = attachToolNameMap(sanitizeAntigravityToolPayload(transformed), toolNameMap); // Credits-first: inject GOOGLE_ONE_AI upfront so we never try the normal // quota path. If credits are exhausted / disabled shouldUseCreditsFirst() // returns false and we fall back to the legacy retry-on-429 flow. if (useCreditsFirst) { - transformedBody = injectCreditsField(transformedBody); + transformedBody = attachToolNameMap(injectCreditsField(transformedBody), toolNameMap); log.debug("AG_CREDITS", "Credits-first enabled (ANTIGRAVITY_CREDITS=always)"); } - return { transformedBody, requestToolNameMap }; + return transformedBody; } /** Debug-only dump of outgoing headers (mask Authorization) and envelope shape. */ @@ -393,7 +399,6 @@ export async function tryCreditsRetry( url: string, headers: Record, transformedBody: Record, - requestToolNameMap: Map | null, credentials: AntigravityCredentials, stream: boolean, signal: AbortSignal | null | undefined, @@ -402,9 +407,13 @@ export async function tryCreditsRetry( onCreditsUpdate: OnAntigravityCreditsUpdate ): Promise { log.info("AG_CREDITS", "Retrying with Google One AI credits"); - const creditsBody = injectCreditsField(transformedBody); + const creditsBody = attachToolNameMap( + injectCreditsField(transformedBody), + getToolNameMap(transformedBody) + ); const serializedCreditsRequest = serializeAntigravityRequest(provider, headers, creditsBody); const finalCreditsHeaders = serializedCreditsRequest.headers; + applyAntigravityClientProfileHeaders(finalCreditsHeaders, credentials, creditsBody); try { await prl.captureCurrentProviderBody( url, @@ -432,7 +441,7 @@ export async function tryCreditsRetry( onCreditsUpdate, url, finalCreditsHeaders, - attachToolNameMap(creditsBody, requestToolNameMap), + creditsBody, signal ); } @@ -440,7 +449,7 @@ export async function tryCreditsRetry( response: creditsResp, url, headers: finalCreditsHeaders, - transformedBody: attachToolNameMap(creditsBody, requestToolNameMap), + transformedBody: creditsBody, }; } @@ -452,6 +461,9 @@ export async function tryCreditsRetry( markCreditsExhausted(accountId); return null; } catch (creditsErr) { + if (signal?.aborted || isAbortError(creditsErr)) { + throw signal?.reason ?? creditsErr; + } handleCreditsFailure(credentials?.accessToken || ""); log.warn("AG_CREDITS", `Credits retry failed: ${creditsErr}`); return null; @@ -470,7 +482,6 @@ export async function tryEmbedLongRetryAfter( url: string, finalHeaders: Record, transformedBody: Record, - requestToolNameMap: Map | null, log: ExecutorLog | null | undefined ): Promise { if ( @@ -498,7 +509,7 @@ export async function tryEmbedLongRetryAfter( response: modifiedResponse, url, headers: finalHeaders, - transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), + transformedBody, }; } catch (err) { log?.warn?.("RETRY", `Failed to embed retryAfterMs: ${err}`); @@ -511,8 +522,7 @@ async function buildUpstreamErrorResult( response: Response, url: string, finalHeaders: Record, - transformedBody: Record, - requestToolNameMap: Map | null + transformedBody: Record ): Promise { const rawBody = await response .clone() @@ -526,7 +536,7 @@ async function buildUpstreamErrorResult( }), url, headers: finalHeaders, - transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), + transformedBody, }; } @@ -544,7 +554,6 @@ async function buildNonStreamingExecuteOnceResult( url: string, finalHeaders: Record, transformedBody: Record, - requestToolNameMap: Map | null, accountId: string, signal: AbortSignal | null | undefined, onCreditsUpdate: OnAntigravityCreditsUpdate @@ -552,7 +561,7 @@ async function buildNonStreamingExecuteOnceResult( // #3229: surface a real upstream error instead of masking a 4xx/5xx as an // empty `chat.completion` envelope. if (!response.ok) { - return buildUpstreamErrorResult(response, url, finalHeaders, transformedBody, requestToolNameMap); + return buildUpstreamErrorResult(response, url, finalHeaders, transformedBody); } if (response.body) { @@ -566,7 +575,7 @@ async function buildNonStreamingExecuteOnceResult( onCreditsUpdate, url, finalHeaders, - attachToolNameMap(transformedBody, requestToolNameMap), + transformedBody, signal ); } @@ -576,7 +585,7 @@ async function buildNonStreamingExecuteOnceResult( response, url, headers: finalHeaders, - transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), + transformedBody, }; } @@ -598,13 +607,12 @@ async function buildStreamingExecuteOnceResult( url: string, finalHeaders: Record, transformedBody: Record, - requestToolNameMap: Map | null, accountId: string, signal: AbortSignal | null | undefined, onCreditsUpdate: OnAntigravityCreditsUpdate ): Promise { if (!response.ok) { - return buildUpstreamErrorResult(response, url, finalHeaders, transformedBody, requestToolNameMap); + return buildUpstreamErrorResult(response, url, finalHeaders, transformedBody); } if (response.body) { @@ -638,7 +646,7 @@ async function buildStreamingExecuteOnceResult( response: tappedResponse, url, headers: finalHeaders, - transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), + transformedBody, }; } @@ -646,7 +654,7 @@ async function buildStreamingExecuteOnceResult( response, url, headers: finalHeaders, - transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), + transformedBody, }; } @@ -657,7 +665,6 @@ export async function buildFinalAntigravityResult( url: string, finalHeaders: Record, transformedBody: Record, - requestToolNameMap: Map | null, accountId: string, signal: AbortSignal | null | undefined, onCreditsUpdate: OnAntigravityCreditsUpdate @@ -668,7 +675,6 @@ export async function buildFinalAntigravityResult( url, finalHeaders, transformedBody, - requestToolNameMap, accountId, signal, onCreditsUpdate @@ -679,7 +685,6 @@ export async function buildFinalAntigravityResult( url, finalHeaders, transformedBody, - requestToolNameMap, accountId, signal, onCreditsUpdate diff --git a/open-sse/services/antigravityClientProfile.ts b/open-sse/services/antigravityClientProfile.ts index fb76f4f105..bb3cbdf61d 100644 --- a/open-sse/services/antigravityClientProfile.ts +++ b/open-sse/services/antigravityClientProfile.ts @@ -3,19 +3,12 @@ import { normalizeAntigravityClientProfile, type AntigravityClientProfile, } from "@/shared/constants/antigravityClientProfile"; -import { getRuntimeArch, getRuntimePlatform } from "./cloudCodeHeaders.ts"; +import { getAntigravityContentHeaders } from "./antigravityHeaders.ts"; +import type { AntigravityCredentialsLike } from "./antigravityIdentity.ts"; import { - deriveAntigravityMachineId, - getAntigravityVscodeSessionId, - type AntigravityCredentialsLike, -} from "./antigravityIdentity.ts"; -import { - antigravityUserAgent, - ANTIGRAVITY_CREDIT_PROBE_API_CLIENT, - ANTIGRAVITY_NODE_API_CLIENT, - getAntigravityLoadCodeAssistMetadata, -} from "./antigravityHeaders.ts"; -import { getCachedAntigravityVersion } from "./antigravityVersion.ts"; + resolveAntigravityCliVersion, + resolveAntigravityIdeVersion, +} from "./antigravityVersion.ts"; export { ANTIGRAVITY_CLIENT_PROFILE_VALUES, @@ -28,6 +21,15 @@ type AntigravityProfileCredentials = AntigravityCredentialsLike & { providerSpecificData?: Record | null; }; +const ABSENT_CONTENT_IDENTITY_HEADERS = [ + "x-client-name", + "x-client-version", + "x-machine-id", + "x-vscode-sessionid", + "X-Goog-Api-Client", + "Client-Metadata", +] as const; + export function getAntigravityClientProfile( credentials?: AntigravityProfileCredentials | null ): AntigravityClientProfile { @@ -41,46 +43,10 @@ export function getAntigravityClientProfile( return normalizeAntigravityClientProfile(fromProviderData); } -function normalizeHarnessPlatform( - platform: NodeJS.Platform | string = getRuntimePlatform() -): string { - return platform === "win32" ? "windows" : platform || "unknown"; -} - -function normalizeHarnessArch(arch: NodeJS.Architecture | string = getRuntimeArch()): string { - switch (arch) { - case "x64": - return "amd64"; - case "ia32": - return "386"; - default: - return arch || "unknown"; - } -} - -function getHarnessPlatformArch( - platform: NodeJS.Platform | string = getRuntimePlatform(), - arch: NodeJS.Architecture | string = getRuntimeArch() -): string { - return `${normalizeHarnessPlatform(platform)}/${normalizeHarnessArch(arch)}`; -} - -export function antigravityHarnessUserAgent( - version = getCachedAntigravityVersion(), - platform: NodeJS.Platform | string = getRuntimePlatform(), - arch: NodeJS.Architecture | string = getRuntimeArch() -): string { - return `antigravity/${version} ${getHarnessPlatformArch(platform, arch)}`; -} - -export function antigravityHarnessLoadCodeAssistUserAgent( - version = getCachedAntigravityVersion() -): string { - return `${antigravityHarnessUserAgent(version)} ${ANTIGRAVITY_NODE_API_CLIENT}`; -} - -export function antigravityHarnessApiClientHeader(): string { - return ANTIGRAVITY_CREDIT_PROBE_API_CLIENT; +export function resolveAntigravityClientVersion( + profile: AntigravityClientProfile +): Promise { + return profile === "cli" ? resolveAntigravityCliVersion() : resolveAntigravityIdeVersion(); } export function removeHeaderCaseInsensitive(headers: Record, name: string): void { @@ -100,67 +66,25 @@ function getProjectHeaderValue(body: unknown): string | null { return project; } -/** Headers used by OAuth/bootstrap calls (loadCodeAssist, token refresh). */ -export function getAntigravityBootstrapHeaders( - profile: AntigravityClientProfile, - accessToken?: string | null -): Record { - const headers: Record = { - "Content-Type": "application/json", - }; - - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}`; - } - - if (profile === "harness") { - headers["User-Agent"] = antigravityHarnessLoadCodeAssistUserAgent(); - headers["X-Goog-Api-Client"] = antigravityHarnessApiClientHeader(); - return headers; - } - - headers["User-Agent"] = antigravityUserAgent(); - headers["Client-Metadata"] = JSON.stringify(getAntigravityLoadCodeAssistMetadata()); - return headers; -} - -/** Apply per-connection client identity to outbound Cloud Code content requests. */ +/** Apply the selected official client identity to a Cloud Code content request. */ export function applyAntigravityClientProfileHeaders( headers: Record, credentials: AntigravityProfileCredentials | null | undefined, body: unknown ): AntigravityClientProfile { const profile = getAntigravityClientProfile(credentials); - const version = getCachedAntigravityVersion(); + const identityHeaders = getAntigravityContentHeaders(profile); - if (profile === "harness") { - headers["User-Agent"] = antigravityHarnessUserAgent(version); - removeHeaderCaseInsensitive(headers, "X-Goog-Api-Client"); - removeHeaderCaseInsensitive(headers, "x-client-name"); - removeHeaderCaseInsensitive(headers, "x-client-version"); - removeHeaderCaseInsensitive(headers, "x-machine-id"); - removeHeaderCaseInsensitive(headers, "x-vscode-sessionid"); - removeHeaderCaseInsensitive(headers, "Client-Metadata"); - } else { - headers["User-Agent"] = antigravityUserAgent(); - headers["x-client-name"] = "antigravity"; - headers["x-client-version"] = version; - const machineId = deriveAntigravityMachineId(credentials); - if (machineId) { - headers["x-machine-id"] = machineId; - } else { - removeHeaderCaseInsensitive(headers, "x-machine-id"); - } - headers["x-vscode-sessionid"] = getAntigravityVscodeSessionId(); - removeHeaderCaseInsensitive(headers, "X-Goog-Api-Client"); - removeHeaderCaseInsensitive(headers, "Client-Metadata"); + removeHeaderCaseInsensitive(headers, "User-Agent"); + headers["User-Agent"] = identityHeaders["User-Agent"]; + for (const name of ABSENT_CONTENT_IDENTITY_HEADERS) { + removeHeaderCaseInsensitive(headers, name); } const project = getProjectHeaderValue(body); + removeHeaderCaseInsensitive(headers, "x-goog-user-project"); if (project) { headers["x-goog-user-project"] = project; - } else { - removeHeaderCaseInsensitive(headers, "x-goog-user-project"); } return profile; diff --git a/open-sse/services/antigravityCredits.ts b/open-sse/services/antigravityCredits.ts index b56bcb7eed..273275e605 100644 --- a/open-sse/services/antigravityCredits.ts +++ b/open-sse/services/antigravityCredits.ts @@ -27,8 +27,8 @@ export function injectCreditsField(body: Record): Record, @@ -36,86 +24,52 @@ function withOptionalBearerAuth( return headers; } -function getAntigravityPlatformInfo(platform: NodeJS.Platform = process.platform): string { - switch (platform) { - case "darwin": - return "Macintosh; Intel Mac OS X 10_15_7"; - case "win32": - return "Windows NT 10.0; Win64; x64"; - case "linux": - default: - return "X11; Linux x86_64"; - } +export function antigravityIdeUserAgent(version = getCachedAntigravityIdeVersion()): string { + return `antigravity/ide/${version} ${ANTIGRAVITY_OS_TYPE}/${ANTIGRAVITY_ARCH}`; } -/** - * Antigravity desktop User-Agent: - * "Antigravity/VERSION (PLATFORM) Chrome/142... Electron/39..." - */ -export function antigravityUserAgent( - version = getCachedAntigravityVersion(), - platform: NodeJS.Platform = process.platform +export function antigravityCliUserAgent( + version = getCachedAntigravityCliVersion(), + authMethod = "consumer" ): string { - return `Antigravity/${version} (${getAntigravityPlatformInfo(platform)}) Chrome/${ANTIGRAVITY_CHROME_VERSION} Electron/${ANTIGRAVITY_ELECTRON_VERSION}`; + return `antigravity/cli/${version} (aidev_client; os_type=${ANTIGRAVITY_OS_TYPE}; arch=${ANTIGRAVITY_ARCH}; auth_method=${authMethod})`; } -export async function resolveAntigravityUserAgent( - platform: NodeJS.Platform = process.platform -): Promise { - const version = await resolveAntigravityVersion(); - return antigravityUserAgent(version, platform); +export function antigravityIdeNodeUserAgent(version = getCachedAntigravityIdeVersion()): string { + return `antigravity/${version} ${ANTIGRAVITY_OS_TYPE}/${ANTIGRAVITY_ARCH} ${ANTIGRAVITY_IDE_NODE_API_CLIENT}`; } -export function antigravityNativeOAuthUserAgent(): string { - return `vscode/1.X.X (Antigravity/${getCachedAntigravityVersion()})`; +export function getAntigravityOAuthUserAgent(profile: AntigravityClientProfile): string { + return profile === "cli" ? antigravityCliUserAgent() : antigravityIdeNodeUserAgent(); } -/** Matches Antigravity-Manager quota.rs: only ideType (no platform — LINUX is rejected). */ +export function getAntigravityContentHeaders( + profile: AntigravityClientProfile, + accessToken?: string | null +): Record { + return withOptionalBearerAuth( + { + "Content-Type": "application/json", + "User-Agent": profile === "cli" ? antigravityCliUserAgent() : antigravityIdeUserAgent(), + }, + accessToken + ); +} + +export function getAntigravityIdeNodeHeaders(accessToken?: string | null): Record { + return withOptionalBearerAuth( + { + "Content-Type": "application/json", + "User-Agent": antigravityIdeNodeUserAgent(), + "X-Goog-Api-Client": ANTIGRAVITY_IDE_NODE_X_GOOG_API_CLIENT, + }, + accessToken + ); +} + +/** Native loadCodeAssist body metadata captured from both official clients. */ export function getAntigravityLoadCodeAssistMetadata(): Record { return { ideType: "ANTIGRAVITY", }; } - -export function getAntigravityLoadCodeAssistClientMetadata(): string { - return JSON.stringify(getAntigravityLoadCodeAssistMetadata()); -} - -export function getAntigravityHeaders( - profile: AntigravityHeaderProfile, - accessToken?: string | null -): Record { - switch (profile) { - case "loadCodeAssist": - return withOptionalBearerAuth( - { - "Content-Type": "application/json", - "User-Agent": antigravityNativeOAuthUserAgent(), - }, - accessToken - ); - case "fetchAvailableModels": - case "models": - return withOptionalBearerAuth( - { - "Content-Type": "application/json", - "User-Agent": antigravityUserAgent(), - }, - accessToken - ); - default: - return withOptionalBearerAuth({ "Content-Type": "application/json" }, accessToken); - } -} - -/** X-Goog-Api-Client used by Antigravity's credit probe path. */ -export function getAntigravityCreditProbeApiClientHeader(): string { - return ANTIGRAVITY_CREDIT_PROBE_API_CLIENT; -} - -/** X-Goog-Api-Client used by harness/native Node Antigravity paths. */ -export function getAntigravityApiClientHeader(): string { - return ANTIGRAVITY_API_CLIENT; -} - -export { ANTIGRAVITY_VERSION }; diff --git a/open-sse/services/antigravityIdentity.ts b/open-sse/services/antigravityIdentity.ts index 08c9fdeb5c..f3934e8460 100644 --- a/open-sse/services/antigravityIdentity.ts +++ b/open-sse/services/antigravityIdentity.ts @@ -1,5 +1,4 @@ import crypto from "node:crypto"; -import { createRequire } from "node:module"; export type AntigravityCredentialsLike = { accessToken?: string | null; @@ -11,25 +10,6 @@ export type AntigravityCredentialsLike = { const FNV_OFFSET_I64 = -3750763034362895579n; const FNV_PRIME_I64 = 1099511628211n; -const PROCESS_SESSION_ID = crypto.randomUUID(); -const require = createRequire(import.meta.url); - -type NodeMachineIdModule = { - machineIdSync?: (original?: boolean) => string; - default?: { - machineIdSync?: (original?: boolean) => string; - }; -}; - -let systemMachineIdSync: ((original?: boolean) => string) | null = null; -try { - const machineIdModule = require("node-machine-id") as NodeMachineIdModule; - systemMachineIdSync = - machineIdModule.machineIdSync ?? machineIdModule.default?.machineIdSync ?? null; -} catch { - systemMachineIdSync = null; -} - function toNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -54,18 +34,10 @@ export function getAntigravityAccountKey( ); } -export function isAntigravityEnterpriseAccount( - credentials?: AntigravityCredentialsLike | null -): boolean { - const email = - toNonEmptyString(credentials?.email) || getProviderDataString(credentials, "email") || ""; - return !!email && !/@(?:gmail|googlemail)\.com$/i.test(email); -} - export function getAntigravityEnvelopeUserAgent( - credentials?: AntigravityCredentialsLike | null -): "antigravity" | "jetski" { - return isAntigravityEnterpriseAccount(credentials) ? "jetski" : "antigravity"; + _credentials?: AntigravityCredentialsLike | null +): "antigravity" { + return "antigravity"; } export function generateAntigravityRequestId(): string { @@ -108,20 +80,3 @@ export function getAntigravitySessionId( generateAntigravitySessionId() ); } - -export function deriveAntigravityMachineId( - _credentials?: AntigravityCredentialsLike | null -): string | null { - try { - const systemMachineId = toNonEmptyString(systemMachineIdSync?.(true)); - if (systemMachineId) return systemMachineId; - } catch { - // Antigravity Manager omits x-machine-id when machine_uid cannot read the OS id. - } - - return null; -} - -export function getAntigravityVscodeSessionId(): string { - return PROCESS_SESSION_ID; -} diff --git a/open-sse/services/antigravityObfuscation.ts b/open-sse/services/antigravityObfuscation.ts deleted file mode 100644 index adbaf3d58b..0000000000 --- a/open-sse/services/antigravityObfuscation.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Sensitive word obfuscation for Antigravity requests. - * - * Obfuscates client tool names (OpenCode, Cursor, Claude Code, etc.) using - * zero-width joiners so Google's backend can't grep for them in request logs. - * Matching ZeroGravity's ZEROGRAVITY_SENSITIVE_WORDS and CLIProxyAPI's cloak system. - */ - -const ZWJ = "\u200d"; - -const DEFAULT_WORDS = [ - "opencode", - "open-code", - "cline", - "roo-cline", - "roo_cline", - "cursor", - "windsurf", - "aider", - "continue.dev", - "copilot", - "avante", - "codecompanion", - "claude code", - "claude-code", - "kilo code", - "kilocode", - "omniroute", -]; - -let words = [...DEFAULT_WORDS]; - -export function setAntigravitySensitiveWords(w: string[]): void { - words = w.length > 0 ? w : [...DEFAULT_WORDS]; -} - -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -// Per-word regex cache — avoids recompiling one RegExp per word on every request body. -// Bounded by distinct configured words; global regexes are safe to reuse (String.replace -// resets lastIndex). -const _obfuscationRegexCache = new Map(); -function getObfuscationRegex(word: string): RegExp { - let regex = _obfuscationRegexCache.get(word); - if (!regex) { - if (_obfuscationRegexCache.size > 2000) _obfuscationRegexCache.clear(); - regex = new RegExp(escapeRegex(word), "gi"); - _obfuscationRegexCache.set(word, regex); - } - return regex; -} - -export function obfuscateSensitiveWords(text: string): string { - if (!text || words.length === 0) return text; - let result = text; - for (const word of words) { - if (!word) continue; - const regex = getObfuscationRegex(word); - result = result.replace(regex, (m) => (m.length <= 1 ? m : m[0] + ZWJ + m.slice(1))); - } - return result; -} diff --git a/open-sse/services/antigravityProjectBootstrap.ts b/open-sse/services/antigravityProjectBootstrap.ts index bd9fb91617..169066d42f 100644 --- a/open-sse/services/antigravityProjectBootstrap.ts +++ b/open-sse/services/antigravityProjectBootstrap.ts @@ -15,21 +15,18 @@ */ import { - getAntigravityHeaders, + getAntigravityContentHeaders, getAntigravityLoadCodeAssistMetadata, } from "./antigravityHeaders.ts"; -import { - getAntigravityBootstrapHeaders, - type AntigravityClientProfile, -} from "./antigravityClientProfile.ts"; -import { ANTIGRAVITY_BASE_URLS } from "../config/antigravityUpstream.ts"; +import type { AntigravityClientProfile } from "./antigravityClientProfile.ts"; +import { ANTIGRAVITY_BOOTSTRAP_BASE_URLS } from "../config/antigravityUpstream.ts"; const LOAD_CODE_ASSIST_PATH = "/v1internal:loadCodeAssist"; const BOOTSTRAP_TIMEOUT_MS = 8_000; /** Ordered list of loadCodeAssist endpoint URLs (mirrors the models discovery order). */ export function getAntigravityLoadCodeAssistUrls(): string[] { - return ANTIGRAVITY_BASE_URLS.map((base) => `${base}${LOAD_CODE_ASSIST_PATH}`); + return ANTIGRAVITY_BOOTSTRAP_BASE_URLS.map((base) => `${base}${LOAD_CODE_ASSIST_PATH}`); } /** Per-token memoization cache (lives for the process lifetime). */ @@ -48,21 +45,23 @@ function getProjectCacheKey(accessToken: string, clientProfile: AntigravityClien async function tryLoadCodeAssist( accessToken: string, fetchImpl: FetchLike, - clientProfile: AntigravityClientProfile + clientProfile: AntigravityClientProfile, + signal?: AbortSignal ): Promise { const urls = getAntigravityLoadCodeAssistUrls(); - const headers = - clientProfile === "harness" - ? getAntigravityBootstrapHeaders(clientProfile, accessToken) - : getAntigravityHeaders("loadCodeAssist", accessToken); + const headers = getAntigravityContentHeaders(clientProfile, accessToken); for (const url of urls) { + if (signal?.aborted) throw signal.reason; try { + // Combine the caller's cancellation signal (#8098) with the per-attempt + // bootstrap timeout so an aborted request tears down immediately. + const timeoutSignal = AbortSignal.timeout(BOOTSTRAP_TIMEOUT_MS); const response = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify({ metadata: getAntigravityLoadCodeAssistMetadata() }), - signal: AbortSignal.timeout(BOOTSTRAP_TIMEOUT_MS), + signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal, }); if (!response.ok) { @@ -93,6 +92,11 @@ async function tryLoadCodeAssist( `[models] antigravity loadCodeAssist at ${url} returned no project id — trying next` ); } catch (error) { + // A caller-initiated abort (#8098) must propagate, not be swallowed as a + // "try next URL" transient — otherwise a cancelled request silently proceeds. + if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) { + throw signal?.reason ?? error; + } const msg = error instanceof Error ? error.message : String(error); console.warn(`[models] antigravity loadCodeAssist threw for ${url}: ${msg} — trying next`); } @@ -114,14 +118,15 @@ async function tryLoadCodeAssist( export async function ensureAntigravityProjectAssigned( accessToken: string, fetchImpl: FetchLike = fetch, - clientProfile: AntigravityClientProfile = "ide" + clientProfile: AntigravityClientProfile = "ide", + signal?: AbortSignal ): Promise { const cacheKey = getProjectCacheKey(accessToken, clientProfile); if (projectCache.has(cacheKey)) { return projectCache.get(cacheKey); // already bootstrapped for this token } - const projectId = await tryLoadCodeAssist(accessToken, fetchImpl, clientProfile); + const projectId = await tryLoadCodeAssist(accessToken, fetchImpl, clientProfile, signal); if (projectId) { projectCache.set(cacheKey, projectId); diff --git a/open-sse/services/antigravityVersion.ts b/open-sse/services/antigravityVersion.ts index 052c98487c..6a8e5c7cc8 100644 --- a/open-sse/services/antigravityVersion.ts +++ b/open-sse/services/antigravityVersion.ts @@ -1,22 +1,28 @@ -const ANTIGRAVITY_RELEASE_FEED_URL = +const ANTIGRAVITY_IDE_RELEASE_FEED_URL = "https://antigravity-auto-updater-974169037036.us-central1.run.app/releases"; -const ANTIGRAVITY_GITHUB_RELEASE_URL = - "https://api.github.com/repos/antigravityide/antigravity/releases/latest"; +const ANTIGRAVITY_CLI_RELEASE_URL = + "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/latest"; export const ANTIGRAVITY_VERSION_CACHE_TTL_MS = 6 * 60 * 60 * 1000; export const ANTIGRAVITY_VERSION_FETCH_TIMEOUT_MS = 5_000; -// Floor version synced with Antigravity-Manager v4.2.0 KNOWN_STABLE_VERSION. -export const ANTIGRAVITY_FALLBACK_VERSION = "4.2.0"; +export const ANTIGRAVITY_IDE_FALLBACK_VERSION = "2.1.1"; +export const ANTIGRAVITY_CLI_FALLBACK_VERSION = "1.1.5"; type VersionCache = { fetchedAt: number; version: string; }; -type FetchLike = typeof fetch; +type ProductVersionState = { + cache: VersionCache | null; + inFlight: Promise | null; +}; -let versionCache: VersionCache | null = null; -let inFlightRequest: Promise | null = null; +type FetchLike = typeof fetch; +type VersionParser = (payload: unknown) => string | null; + +const ideState: ProductVersionState = { cache: null, inFlight: null }; +const cliState: ProductVersionState = { cache: null, inFlight: null }; function normalizeVersion(value: unknown): string | null { if (typeof value !== "string") return null; @@ -34,13 +40,13 @@ function compareSemver(a: string, b: string): number { return 0; } -function pickNewestVersion(...versions: Array): string { +function pickNewestVersion(...versions: unknown[]): string | null { return versions .map((version) => normalizeVersion(version)) .filter((version): version is string => !!version) - .reduce( - (best, version) => (compareSemver(version, best) > 0 ? version : best), - ANTIGRAVITY_FALLBACK_VERSION + .reduce( + (best, version) => (!best || compareSemver(version, best) > 0 ? version : best), + null ); } @@ -67,97 +73,107 @@ async function fetchJsonWithTimeout(fetchImpl: FetchLike, url: string): Promise< } } -function parseOfficialReleaseFeed(payload: unknown): string | null { +function parseIdeReleaseFeed(payload: unknown): string | null { if (!Array.isArray(payload)) return null; - - for (const entry of payload) { - const version = normalizeVersion((entry as { version?: unknown })?.version); - if (version) return version; - } - - return null; + return pickNewestVersion(...payload.map((entry) => (entry as { version?: unknown })?.version)); } -function parseGitHubRelease(payload: unknown): string | null { +function parseCliRelease(payload: unknown): string | null { if (!payload || typeof payload !== "object") return null; - - const candidate = - (payload as { tag_name?: unknown }).tag_name ?? (payload as { name?: unknown }).name; - - return normalizeVersion(candidate); + const release = payload as { name?: unknown; tag_name?: unknown }; + return normalizeVersion(release.tag_name ?? release.name); } -async function fetchLatestAntigravityVersion(fetchImpl: FetchLike): Promise { - const sources = [ - { - parse: parseOfficialReleaseFeed, - url: ANTIGRAVITY_RELEASE_FEED_URL, - }, - { - parse: parseGitHubRelease, - url: ANTIGRAVITY_GITHUB_RELEASE_URL, - }, - ]; - - for (const source of sources) { - try { - const payload = await fetchJsonWithTimeout(fetchImpl, source.url); - const version = source.parse(payload); - if (version) return version; - } catch { - // Try the next source and fall back to the last known good version if all fail. - } - } - - return null; -} - -export async function resolveAntigravityVersion(fetchImpl: FetchLike = fetch): Promise { +async function resolveProductVersion( + state: ProductVersionState, + fallbackVersion: string, + sourceUrl: string, + parsePayload: VersionParser, + fetchImpl: FetchLike +): Promise { const now = Date.now(); - - if (versionCache && now - versionCache.fetchedAt < ANTIGRAVITY_VERSION_CACHE_TTL_MS) { - return versionCache.version; + if (state.cache && now - state.cache.fetchedAt < ANTIGRAVITY_VERSION_CACHE_TTL_MS) { + return pickNewestVersion(state.cache.version, fallbackVersion) ?? fallbackVersion; } - if (inFlightRequest) { - return inFlightRequest; + if (state.inFlight) { + return state.inFlight; } - inFlightRequest = (async () => { - const resolved = await fetchLatestAntigravityVersion(fetchImpl); - const version = resolved - ? pickNewestVersion(resolved, ANTIGRAVITY_FALLBACK_VERSION) - : pickNewestVersion(versionCache?.version, ANTIGRAVITY_FALLBACK_VERSION); + state.inFlight = (async () => { + let resolved: string | null = null; + try { + resolved = parsePayload(await fetchJsonWithTimeout(fetchImpl, sourceUrl)); + } catch { + resolved = null; + } + + const version = + pickNewestVersion(resolved, state.cache?.version, fallbackVersion) ?? fallbackVersion; if (resolved) { - versionCache = { + state.cache = { fetchedAt: Date.now(), version, }; } - return version; })(); try { - return await inFlightRequest; + return await state.inFlight; } finally { - inFlightRequest = null; + state.inFlight = null; } } -export function getCachedAntigravityVersion(): string { - return versionCache?.version || ANTIGRAVITY_FALLBACK_VERSION; +function seedVersionCache(state: ProductVersionState, version: string, fetchedAt: number): void { + const normalized = normalizeVersion(version); + if (!normalized) { + throw new TypeError(`Invalid Antigravity version: ${version}`); + } + state.cache = { fetchedAt, version: normalized }; } -export function seedAntigravityVersionCache(version: string, fetchedAt = Date.now()): void { - versionCache = { - fetchedAt, - version, - }; +export function resolveAntigravityIdeVersion(fetchImpl: FetchLike = fetch): Promise { + return resolveProductVersion( + ideState, + ANTIGRAVITY_IDE_FALLBACK_VERSION, + ANTIGRAVITY_IDE_RELEASE_FEED_URL, + parseIdeReleaseFeed, + fetchImpl + ); } -export function clearAntigravityVersionCache(): void { - versionCache = null; - inFlightRequest = null; +export function resolveAntigravityCliVersion(fetchImpl: FetchLike = fetch): Promise { + return resolveProductVersion( + cliState, + ANTIGRAVITY_CLI_FALLBACK_VERSION, + ANTIGRAVITY_CLI_RELEASE_URL, + parseCliRelease, + fetchImpl + ); +} + +export function getCachedAntigravityIdeVersion(): string { + return ideState.cache?.version ?? ANTIGRAVITY_IDE_FALLBACK_VERSION; +} + +export function getCachedAntigravityCliVersion(): string { + return cliState.cache?.version ?? ANTIGRAVITY_CLI_FALLBACK_VERSION; +} + +export function seedAntigravityIdeVersionCache(version: string, fetchedAt = Date.now()): void { + seedVersionCache(ideState, version, fetchedAt); +} + +export function seedAntigravityCliVersionCache(version: string, fetchedAt = Date.now()): void { + seedVersionCache(cliState, version, fetchedAt); +} + +export function clearAntigravityVersionCaches(): void { + ideState.cache = null; + ideState.inFlight = null; + cliState.cache = null; + cliState.inFlight = null; } diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index fc9d698d0a..c9077bf1db 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -77,11 +77,8 @@ const PROVIDER_MODEL_ALIASES: ProviderModelAliasMap = { "syn:small:text": "hf:zai-org/GLM-4.7-Flash", "syn:nemotron-3-super": "hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4", }, - // Antigravity model aliases must be applied by the Antigravity executor, not by - // the global model resolver. Applying them here rewrites the client-visible model - // before credential/account routing and before UI/logging, causing clean IDs like - // gemini-3.5-flash-high to be exposed and retried as upstream-only legacy ids such - // as gemini-3-flash-agent. The executor owns provider-wire normalization. + // Antigravity public model ids already match the upstream wire ids. Keep this map + // empty so the global resolver cannot rewrite them before routing or logging. antigravity: {}, kiro: { "claude-opus-4-7": "claude-opus-4.7", diff --git a/open-sse/services/usage/antigravity.ts b/open-sse/services/usage/antigravity.ts index 84f01420e0..7b2f2ce13a 100644 --- a/open-sse/services/usage/antigravity.ts +++ b/open-sse/services/usage/antigravity.ts @@ -12,8 +12,9 @@ import { PROVIDERS } from "../../config/constants.ts"; import { + ANTIGRAVITY_BOOTSTRAP_BASE_URLS, + ANTIGRAVITY_RUNTIME_BASE_URLS, getAntigravityFetchAvailableModelsUrls, - ANTIGRAVITY_BASE_URLS, } from "../../config/antigravityUpstream.ts"; import { isUserCallableAntigravityModelId, @@ -23,12 +24,11 @@ import { isUserCallableAgyModelId } from "../../config/agyModels.ts"; import { getDbInstance } from "@/lib/db/core"; import { applyAntigravityClientProfileHeaders, - getAntigravityBootstrapHeaders, getAntigravityClientProfile, + type AntigravityClientProfile, } from "../antigravityClientProfile.ts"; import { - antigravityUserAgent, - getAntigravityHeaders, + getAntigravityContentHeaders, getAntigravityLoadCodeAssistMetadata, } from "../antigravityHeaders.ts"; import { @@ -53,7 +53,7 @@ type SubscriptionCacheEntry = { const ANTIGRAVITY_CONFIG = { quotaApiUrls: getAntigravityFetchAvailableModelsUrls(), - loadProjectApiUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist", + loadProjectApiUrl: `${ANTIGRAVITY_BOOTSTRAP_BASE_URLS[0]}/v1internal:loadCodeAssist`, tokenUrl: "https://oauth2.googleapis.com/token", get clientId() { return PROVIDERS.antigravity.clientId; @@ -61,9 +61,6 @@ const ANTIGRAVITY_CONFIG = { get clientSecret() { return PROVIDERS.antigravity.clientSecret; }, - get userAgent() { - return antigravityUserAgent(); - }, }; const _antigravitySubCache = new Map(); @@ -147,8 +144,7 @@ function getAntigravityLocalUsageUnits( AND timestamp < ?` ) .get(provider, connectionId, modelId, windowStart, windowEnd) as - | { tokens?: unknown } - | undefined; + { tokens?: unknown } | undefined; const tokens = Number(row?.tokens || 0); if (!Number.isFinite(tokens) || tokens <= 0) return 0; @@ -180,18 +176,23 @@ function applyLocalUsageFallback( }; } -function buildAntigravityUsageCacheKey(accessToken: string, projectId?: string | null): string { - return `${accessToken.substring(0, 16)}:${projectId || "default"}`; +function buildAntigravityUsageCacheKey( + accessToken: string, + projectId: string | null | undefined, + clientProfile: AntigravityClientProfile +): string { + return `${accessToken.substring(0, 16)}:${projectId || "default"}:${clientProfile}`; } async function fetchAntigravityAvailableModelsCached( accessToken: string, projectId?: string | null, + clientProfile: AntigravityClientProfile = "ide", options: AntigravityUsageOptions = {} ): Promise { if (!accessToken) throw new Error("Access token is required"); - const cacheKey = buildAntigravityUsageCacheKey(accessToken, projectId); + const cacheKey = buildAntigravityUsageCacheKey(accessToken, projectId, clientProfile); const cached = _antigravityAvailableModelsCache.get(cacheKey); if ( !options.forceRefresh && @@ -212,7 +213,7 @@ async function fetchAntigravityAvailableModelsCached( try { response = await fetch(quotaApiUrl, { method: "POST", - headers: getAntigravityHeaders("fetchAvailableModels", accessToken), + headers: getAntigravityContentHeaders(clientProfile, accessToken), body: JSON.stringify(projectId ? { project: projectId } : {}), signal: AbortSignal.timeout(10000), }); @@ -251,11 +252,12 @@ async function fetchAntigravityAvailableModelsCached( async function fetchAntigravityUserQuotaCached( accessToken: string, projectId?: string | null, + clientProfile: AntigravityClientProfile = "ide", options: AntigravityUsageOptions = {} ): Promise { if (!accessToken || !projectId) return null; - const cacheKey = buildAntigravityUsageCacheKey(accessToken, projectId); + const cacheKey = buildAntigravityUsageCacheKey(accessToken, projectId, clientProfile); const cached = _antigravityUserQuotaCache.get(cacheKey); if ( !options.forceRefresh && @@ -274,10 +276,7 @@ async function fetchAntigravityUserQuotaCached( "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", { method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, + headers: getAntigravityContentHeaders(clientProfile, accessToken), body: JSON.stringify({ project: projectId }), signal: AbortSignal.timeout(10000), } @@ -420,7 +419,12 @@ async function probeAntigravityCreditBalance( ): Promise { if (!accessToken) return null; - const cacheKey = buildAntigravityUsageCacheKey(accessToken, projectId || accountId); + const clientProfile = getAntigravityClientProfile({ providerSpecificData }); + const cacheKey = buildAntigravityUsageCacheKey( + accessToken, + projectId || accountId, + clientProfile + ); const cached = _antigravityCreditProbeCache.get(cacheKey); if ( !options.forceRefresh && @@ -467,7 +471,7 @@ async function probeAntigravityCreditBalanceUncached( if (!projectId) return null; // Try all base URLs (some accounts only work with specific endpoints) - for (const baseUrl of ANTIGRAVITY_BASE_URLS) { + for (const baseUrl of ANTIGRAVITY_RUNTIME_BASE_URLS) { const url = `${baseUrl}/v1internal:streamGenerateContent?alt=sse`; const sessionId = getAntigravitySessionId({ connectionId: accountId, projectId }); @@ -567,6 +571,7 @@ export async function getAntigravityUsage( let subscriptionInfo: unknown = null; try { + const clientProfile = getAntigravityClientProfile({ providerSpecificData }); subscriptionInfo = await getAntigravitySubscriptionInfoCached( accessToken, providerSpecificData, @@ -593,9 +598,11 @@ export async function getAntigravityUsage( // Read cached credit balance (hydrated from DB on first access) let creditBalance = getAntigravityRemainingCredits(accountId); - // If no cached balance and credits mode is enabled, fire a minimal probe + // Only an explicit refresh in always mode may proactively spend credits to discover + // the balance. Automatic/scheduled refreshes must use the cached balance (if any) + // rather than adding credit-bearing inference calls after normal user requests. const creditsMode = getCreditsMode(); - if ((options.forceRefresh || creditBalance === null) && creditsMode !== "off") { + if (options.forceRefresh === true && creditsMode === "always") { creditBalance = await probeAntigravityCreditBalance( accessToken, accountId, @@ -606,9 +613,9 @@ export async function getAntigravityUsage( } const [data, userQuotaData, weeklyQuotas] = await Promise.all([ - fetchAntigravityAvailableModelsCached(accessToken, projectId, options), - fetchAntigravityUserQuotaCached(accessToken, projectId, options), - fetchAndParseAntigravityWeeklyQuotas(accessToken, projectId, options), // #4017 + fetchAntigravityAvailableModelsCached(accessToken, projectId, clientProfile, options), + fetchAntigravityUserQuotaCached(accessToken, projectId, clientProfile, options), + fetchAndParseAntigravityWeeklyQuotas(accessToken, projectId, clientProfile, options), // #4017 ]); const dataObj = toRecord(data); if (dataObj.__antigravityForbidden === true) { @@ -781,10 +788,7 @@ async function getAntigravitySubscriptionInfo( const profile = getAntigravityClientProfile({ providerSpecificData }); const response = await fetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, { method: "POST", - headers: - profile === "harness" - ? getAntigravityBootstrapHeaders(profile, accessToken) - : getAntigravityHeaders("loadCodeAssist", accessToken), + headers: getAntigravityContentHeaders(profile, accessToken), body: JSON.stringify({ metadata: getAntigravityLoadCodeAssistMetadata() }), }); diff --git a/open-sse/services/usage/antigravityWeeklyQuota.ts b/open-sse/services/usage/antigravityWeeklyQuota.ts index e7d94374ba..3aa4e78d18 100644 --- a/open-sse/services/usage/antigravityWeeklyQuota.ts +++ b/open-sse/services/usage/antigravityWeeklyQuota.ts @@ -19,6 +19,8 @@ import { toRecord, toNumber } from "./scalars.ts"; import { type UsageQuota, parseResetTime } from "./quota.ts"; +import { getAntigravityContentHeaders } from "../antigravityHeaders.ts"; +import type { AntigravityClientProfile } from "../antigravityClientProfile.ts"; type JsonRecord = Record; @@ -43,8 +45,12 @@ const _weeklyQuotaCacheCleanupTimer = setInterval( ); _weeklyQuotaCacheCleanupTimer.unref?.(); -function buildCacheKey(accessToken: string, projectId?: string | null): string { - return `${accessToken.substring(0, 16)}:${projectId || "default"}`; +function buildCacheKey( + accessToken: string, + projectId: string | null | undefined, + clientProfile: AntigravityClientProfile +): string { + return `${accessToken.substring(0, 16)}:${projectId || "default"}:${clientProfile}`; } /** @@ -55,13 +61,18 @@ function buildCacheKey(accessToken: string, projectId?: string | null): string { export async function fetchAntigravityUserQuotaSummaryCached( accessToken: string, projectId?: string | null, + clientProfile: AntigravityClientProfile = "ide", options: AntigravityWeeklyQuotaOptions = {} ): Promise { if (!accessToken || !projectId) return null; - const cacheKey = buildCacheKey(accessToken, projectId); + const cacheKey = buildCacheKey(accessToken, projectId, clientProfile); const cached = _weeklyQuotaCache.get(cacheKey); - if (!options.forceRefresh && cached && Date.now() - cached.fetchedAt < WEEKLY_QUOTA_CACHE_TTL_MS) { + if ( + !options.forceRefresh && + cached && + Date.now() - cached.fetchedAt < WEEKLY_QUOTA_CACHE_TTL_MS + ) { return cached.data; } @@ -74,10 +85,7 @@ export async function fetchAntigravityUserQuotaSummaryCached( "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary", { method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, + headers: getAntigravityContentHeaders(clientProfile, accessToken), body: JSON.stringify({ project: projectId }), signal: AbortSignal.timeout(10000), } @@ -184,8 +192,14 @@ function parseGroupWeeklyQuota(group: JsonRecord): { key: string; quota: UsageQu export async function fetchAndParseAntigravityWeeklyQuotas( accessToken: string, projectId: string | undefined | null, + clientProfile: AntigravityClientProfile = "ide", options: AntigravityWeeklyQuotaOptions = {} ): Promise> { - const data = await fetchAntigravityUserQuotaSummaryCached(accessToken, projectId, options); + const data = await fetchAntigravityUserQuotaSummaryCached( + accessToken, + projectId, + clientProfile, + options + ); return parseAntigravityWeeklyQuotas(data); } diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index d96a1b8386..f5c1a3ee04 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -69,7 +69,7 @@ type GeminiRequest = { contents?: GeminiContent[]; [key: string]: unknown; generationConfig: GeminiGenerationConfig; - safetySettings: unknown; + safetySettings?: unknown; systemInstruction?: GeminiContent; tools?: Array<{ functionDeclarations?: GeminiFunctionDeclaration[]; @@ -83,7 +83,7 @@ type CloudCodeEnvelope = { project: string; model?: string; user_prompt_id?: string; - userAgent?: "antigravity" | "jetski" | string; + userAgent?: string; requestId?: string; requestType?: string; enabledCreditTypes?: string[]; @@ -611,12 +611,20 @@ export function openaiToCloudCodeGeminiRequest( signaturelessToolCallMode?: "native" | "text" | "context"; } = {} ) { - return openaiToGeminiBase(model, body, stream, { + const request = openaiToGeminiBase(model, body, stream, { stripNamespace: true, signatureNamespace: options.signatureNamespace, signaturelessToolCallMode: options.signaturelessToolCallMode, supportsSignatureBypass: true, }); + + // Standard Gemini requests retain the historical all-OFF defaults, but Cloud Code + // must receive safety policy only when the caller explicitly supplied it. + if (!Object.prototype.hasOwnProperty.call(body, "safetySettings")) { + delete request.safetySettings; + } + + return request; } function wrapInCloudCodeEnvelope(model, cloudCodeRequest, credentials = null) { @@ -654,7 +662,6 @@ function wrapInCloudCodeEnvelope(model, cloudCodeRequest, credentials = null) { model: cleanModel, userAgent: getAntigravityEnvelopeUserAgent(credentials), requestType: "agent", - enabledCreditTypes: ["GOOGLE_ONE_AI"], }; if (cloudCodeRequest._toolNameMap instanceof Map && cloudCodeRequest._toolNameMap.size > 0) { envelope._toolNameMap = cloudCodeRequest._toolNameMap; diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index a92efe4e02..b8ec40407f 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -2598,7 +2598,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo }; const FREE_STACK_PRESET_MODELS = [ - { model: "agy/gemini-3.5-flash-medium", weight: 0 }, + { model: "agy/gemini-3.5-flash-low", weight: 0 }, { model: "kr/claude-sonnet-4.5", weight: 0 }, { model: "if/kimi-k2-thinking", weight: 0 }, { model: "if/qwen3-coder-plus", weight: 0 }, @@ -2611,8 +2611,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo { model: "cu/claude-4.6-opus-high", weight: 0 }, { model: "antigravity/claude-sonnet-4-6", weight: 0 }, { model: "cu/claude-4.6-sonnet-high", weight: 0 }, - { model: "antigravity/gemini-3.1-pro-high", weight: 0 }, - { model: "antigravity/gemini-3-pro-high", weight: 0 }, + { model: "antigravity/gemini-pro-agent", weight: 0 }, ]; const applyTemplate = (template) => { diff --git a/src/app/api/providers/[id]/models/discovery/normalizers.ts b/src/app/api/providers/[id]/models/discovery/normalizers.ts index a95c5d437f..4699589b53 100644 --- a/src/app/api/providers/[id]/models/discovery/normalizers.ts +++ b/src/app/api/providers/[id]/models/discovery/normalizers.ts @@ -4,14 +4,14 @@ import { getAntigravityModelsDiscoveryUrls, getAntigravityFetchAvailableModelsUrls, } from "@omniroute/open-sse/config/antigravityUpstream.ts"; -import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; +import { getAntigravityContentHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; +import { resolveAntigravityClientVersion } from "@omniroute/open-sse/services/antigravityClientProfile.ts"; import { getClientVisibleAntigravityModelName, isUserCallableAntigravityModelId, toClientAntigravityModelId, } from "@omniroute/open-sse/config/antigravityModelAliases.ts"; import { normalizeAntigravityClientProfile } from "@/shared/constants/antigravityClientProfile"; -import { resolveAntigravityVersion } from "@omniroute/open-sse/services/antigravityVersion.ts"; import { ensureAntigravityProjectAssigned } from "@omniroute/open-sse/services/antigravityProjectBootstrap.ts"; import { asRecord, toNonEmptyString } from "./helpers"; @@ -90,12 +90,8 @@ export async function fetchAntigravityDiscoveryModelsCached( if (inflight) return inflight; const promise = (async () => { - await resolveAntigravityVersion(); - await ensureAntigravityProjectAssigned( - accessToken, - fetch, - normalizeAntigravityClientProfile(asRecord(providerSpecificData).clientProfile) - ); + await resolveAntigravityClientVersion(profile); + await ensureAntigravityProjectAssigned(accessToken, fetch, profile); for (const discoveryUrl of [ ...getAntigravityFetchAvailableModelsUrls(), @@ -107,7 +103,7 @@ export async function fetchAntigravityDiscoveryModelsCached( guard: getProviderOutboundGuard(), proxyConfig: proxy, method: "POST", - headers: getAntigravityHeaders("models", accessToken), + headers: getAntigravityContentHeaders(profile, accessToken), body: JSON.stringify({}), }); diff --git a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts index 90ad2615a9..6d67077914 100644 --- a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts +++ b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts @@ -4,7 +4,7 @@ import { getGrokBuildModelsHeaders, GROK_BUILD_MODELS_URL, } from "@omniroute/open-sse/config/grokBuild.ts"; -import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; +import { getAntigravityContentHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser"; import { CLINE_MODELS_ENDPOINT, @@ -410,7 +410,7 @@ export const PROVIDER_MODELS_CONFIG: Record = antigravity: { url: getAntigravityModelsDiscoveryUrls()[0], method: "POST", - headers: getAntigravityHeaders("models"), + headers: getAntigravityContentHeaders("ide"), authHeader: "Authorization", authPrefix: "Bearer ", body: {}, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 8d5da30046..6130510216 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "الملف الشخصي للعميل", "antigravityClientProfileHint": "اختر هوية عميل Antigravity التي يقدمها OmniRoute إلى واجهة برمجة التطبيقات.", "antigravityClientProfileIde": "بيئة تطوير متكاملة", - "antigravityClientProfileHarness": "أداة التشغيل / CLI", + "antigravityClientProfileCli": "أداة التشغيل / CLI", "codexFastTierActiveChip": "طبقة Codex السريعة نشطة", "tierFast": "سريع", "antigravityProjectIdLabel": "معرّف مشروع Google Cloud", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 50cc23108b..db976b0d1f 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Müştəri profili", "antigravityClientProfileHint": "OmniRoute-un API-yə hansı Antigravity müştəri kimliyini təqdim edəcəyini seçin.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast səviyyəsi aktivdir", "tierFast": "Sürətli", "antigravityProjectIdLabel": "Google Cloud Project ID", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index b30164f599..b8b7620315 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Профил на клиента", "antigravityClientProfileHint": "Изберете коя клиентска идентичност на Antigravity да представя OmniRoute пред API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Нивото Codex Fast е активно", "tierFast": "Бърз", "antigravityProjectIdLabel": "Идентификационен номер на Google Cloud Project", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 86a12bac1f..33995accf9 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "ক্লায়েন্ট প্রোফাইল", "antigravityClientProfileHint": "OmniRoute কোন Antigravity ক্লায়েন্ট পরিচয় API-এর কাছে উপস্থাপন করবে তা চয়ন করুন।", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast টিয়ার সক্রিয় রয়েছে", "tierFast": "দ্রুত", "antigravityProjectIdLabel": "Google ক্লাউড প্রকল্প আইডি", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 199f6e10aa..45bd881d08 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Profil klienta", "antigravityClientProfileHint": "Vyberte, jakou identitu klienta Antigravity bude OmniRoute prezentovat rozhraní API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Úroveň Codex Fast je aktivní", "tierFast": "Fast", "antigravityProjectIdLabel": "ID projektu Google Cloud", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 38e42b316c..0ae7fa7c25 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Klientprofil", "antigravityClientProfileHint": "Vælg, hvilken Antigravity-klientidentitet OmniRoute præsenterer for API'en.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast-niveau er aktivt", "tierFast": "Hurtig", "antigravityProjectIdLabel": "Google Cloud-projekt-id", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index b3780342be..99d7e0a24e 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Client-Profil", "antigravityClientProfileHint": "Wählen Sie, welche Antigravity-Client-Identität OmniRoute der API übermittelt.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast-Stufe ist aktiv", "tierFast": "Schnell", "antigravityProjectIdLabel": "Google Cloud-Projekt-ID", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 4aeae301a5..8c35af1b2e 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5463,7 +5463,7 @@ "antigravityClientProfileLabel": "Client profile", "antigravityClientProfileHint": "Choose which Antigravity client identity OmniRoute presents to the API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "CLI", "codexFastTierActiveChip": "Codex Fast tier is active", "tierFast": "Fast", "antigravityProjectIdLabel": "Google Cloud Project ID", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 2526ccb68a..bb2d5c391e 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Perfil de cliente", "antigravityClientProfileHint": "Elija qué identidad de cliente de Antigravity presenta OmniRoute a la API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "El nivel Codex Fast está activo", "tierFast": "Rápido", "antigravityProjectIdLabel": "ID del proyecto de Google Cloud", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 8dec6ed2a9..4d00e852c9 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "پروفایل کلاینت", "antigravityClientProfileHint": "انتخاب کنید که OmniRoute کدام هویت کلاینت Antigravity را به API ارائه دهد.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "سطح Codex Fast فعال است", "tierFast": "سریع", "antigravityProjectIdLabel": "شناسه پروژه Google Cloud", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index c9ba02fd98..4c92835aaf 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Asiakasprofiili", "antigravityClientProfileHint": "Valitse, minkä Antigravity-asiakasidentiteetin OmniRoute esittää API:lle.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast -taso on aktiivinen", "tierFast": "Nopea", "antigravityProjectIdLabel": "Google Cloud Project ID", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index ed049ad782..2c30c309c2 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Profil client", "antigravityClientProfileHint": "Choisissez l'identité de client Antigravity qu'OmniRoute présente à l'API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Le niveau Codex Fast est actif", "tierFast": "Rapide", "antigravityProjectIdLabel": "ID du projet Google Cloud", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 5a8fc0d11f..c308edae68 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "ક્લાયન્ટ પ્રોફાઇલ", "antigravityClientProfileHint": "OmniRoute API સમક્ષ કઈ Antigravity ક્લાયન્ટ ઓળખ રજૂ કરે છે તે પસંદ કરો.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast ટાયર સક્રિય છે", "tierFast": "ઝડપી", "antigravityProjectIdLabel": "Google ક્લાઉડ પ્રોજેક્ટ ID", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 5546ba3dad..eb057b53cc 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "פרופיל לקוח", "antigravityClientProfileHint": "בחר איזו זהות לקוח של Antigravity תציג OmniRoute ל-API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "דרגת Codex Fast פעילה", "tierFast": "מהיר", "antigravityProjectIdLabel": "מזהה פרויקט של Google Cloud", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 3b14d41bf5..03b7dadc23 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "क्लाइंट प्रोफ़ाइल", "antigravityClientProfileHint": "चुनें कि OmniRoute API को कौन सी Antigravity क्लाइंट पहचान प्रस्तुत करता है।", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast टियर सक्रिय है", "tierFast": "फ़ास्ट", "antigravityProjectIdLabel": "Google क्लाउड प्रोजेक्ट आईडी", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 34984a65cb..7027d4131c 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Kliensprofil", "antigravityClientProfileHint": "Válassza ki, melyik Antigravity kliensidentitást mutassa az OmniRoute az API felé.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "A Codex Fast szint aktív", "tierFast": "Fast", "antigravityProjectIdLabel": "Google Cloud Project ID", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index aedbf2fa4e..178cf8de09 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Profil klien", "antigravityClientProfileHint": "Pilih identitas klien Antigravity mana yang disajikan OmniRoute ke API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Tingkat Codex Fast aktif", "tierFast": "Cepat", "antigravityProjectIdLabel": "ID Proyek Google Cloud", diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index 15982b4927..123c6229f0 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Profil klien", "antigravityClientProfileHint": "Pilih identitas klien Antigravity mana yang ditampilkan OmniRoute ke API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Tingkat Codex Fast aktif", "tierFast": "Cepat", "antigravityProjectIdLabel": "ID Proyek Google Cloud", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index a30a71759b..1f0d2a2588 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Profilo client", "antigravityClientProfileHint": "Scegli quale identità client Antigravity viene presentata da OmniRoute all'API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Il livello Codex Fast è attivo", "tierFast": "Fast", "antigravityProjectIdLabel": "ID del progetto Google Cloud", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 0631b6abf2..86d124e474 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "クライアントプロファイル", "antigravityClientProfileHint": "OmniRoute が API に提示する Antigravity クライアント識別情報を選択します。", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast ティアがアクティブです", "tierFast": "Fast", "antigravityProjectIdLabel": "Google Cloud プロジェクト ID", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 6e77f587be..93a6a50646 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "클라이언트 프로필", "antigravityClientProfileHint": "OmniRoute가 API에 제시할 Antigravity 클라이언트 식별 정보를 선택합니다.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast 티어가 활성화되어 있습니다", "tierFast": "Fast", "antigravityProjectIdLabel": "Google Cloud 프로젝트 ID", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index fc875a74f5..3294faf9f3 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "क्लायंट प्रोफाइल", "antigravityClientProfileHint": "OmniRoute API ला कोणती Antigravity क्लायंट ओळख सादर करते ते निवडा.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast टियर सक्रिय आहे", "tierFast": "फास्ट", "antigravityProjectIdLabel": "Google क्लाउड प्रोजेक्ट आयडी", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 696be8de02..69c4ffc94c 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Profil klien", "antigravityClientProfileHint": "Pilih identiti klien Antigravity yang dibentangkan oleh OmniRoute kepada API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Tier Fast Codex aktif", "tierFast": "Fast", "antigravityProjectIdLabel": "ID Projek Awan Google", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 8c37635f7b..3b5148deb1 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Clientprofiel", "antigravityClientProfileHint": "Kies welke Antigravity-clientidentiteit OmniRoute aan de API presenteert.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast-tier is actief", "tierFast": "Snel", "antigravityProjectIdLabel": "Google Cloud-project-ID", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 7a0f525576..de3df2c560 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Klientprofil", "antigravityClientProfileHint": "Velg hvilken Antigravity-klientidentitet OmniRoute presenterer for API-et.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast-nivået er aktivt", "tierFast": "Fast", "antigravityProjectIdLabel": "Google Cloud Project ID", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index d8aec33d5e..4a53252cd8 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Profile ng client", "antigravityClientProfileHint": "Pumili kung aling identidad ng Antigravity client ang ipapakita ng OmniRoute sa API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Aktibo ang Codex Fast tier", "tierFast": "Fast", "antigravityProjectIdLabel": "Google Cloud Project ID", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 81a57848fa..07658801a2 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Profil klienta", "antigravityClientProfileHint": "Wybierz, którą tożsamość klienta Antigravity OmniRoute ma prezentować w API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "CLI", "codexFastTierActiveChip": "Klasa usług (tier) Codex Fast jest aktywna", "tierFast": "Fast", "antigravityProjectIdLabel": "Google Cloud Project ID", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 3be07d311e..4e027f23ba 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -5462,7 +5462,7 @@ "antigravityClientProfileLabel": "Perfil do cliente", "antigravityClientProfileHint": "Escolha qual identidade de cliente Antigravity o OmniRoute apresenta à API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "CLI", "codexFastTierActiveChip": "A Camada Rápida do Codex está ativa", "tierFast": "Rápido", "antigravityProjectIdLabel": "ID do projeto do Google Cloud", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index a3055f699e..16e2764abc 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Perfil do cliente", "antigravityClientProfileHint": "Escolha qual a identidade de cliente Antigravity que o OmniRoute apresenta à API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "O nível Codex Fast está ativo", "tierFast": "Fast", "antigravityProjectIdLabel": "ID do projeto do Google Cloud", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 1e68c66d0b..1c13d98eb6 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Profil client", "antigravityClientProfileHint": "Alegeți ce identitate de client Antigravity prezintă OmniRoute către API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Nivelul Codex Fast este activ", "tierFast": "Fast", "antigravityProjectIdLabel": "ID-ul proiectului Google Cloud", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index b08bcc1151..51692d8d46 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Профиль клиента", "antigravityClientProfileHint": "Выберите, какую идентичность клиента Antigravity OmniRoute представляет API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "CLI", "codexFastTierActiveChip": "Активен уровень Codex Fast", "tierFast": "Быстро", "antigravityProjectIdLabel": "Идентификатор проекта Google Cloud", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 7136e2e2b2..3c45da3358 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Profil klienta", "antigravityClientProfileHint": "Vyberte, ktorú identitu klienta Antigravity má OmniRoute prezentovať rozhraniu API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Úroveň Codex Fast je aktívna", "tierFast": "Fast", "antigravityProjectIdLabel": "ID projektu Google Cloud", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 7fe6fda9bf..32c124b0b9 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Klientprofil", "antigravityClientProfileHint": "Välj vilken Antigravity-klientidentitet OmniRoute presenterar för API:et.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast-nivån är aktiv", "tierFast": "Fast", "antigravityProjectIdLabel": "Google Cloud Project ID", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index e4c82898d6..3c4cf4a000 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Wasifu wa mteja", "antigravityClientProfileHint": "Chagua utambulisho gani wa mteja wa Antigravity ambao OmniRoute inawasilisha kwa API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Kiwango cha Codex Fast kiko hai", "tierFast": "Haraka", "antigravityProjectIdLabel": "Kitambulisho cha Mradi wa Wingu la Google", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 34203aaf9b..69043a30aa 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "கிளையன்ட் சுயவிவரம்", "antigravityClientProfileHint": "API-க்கு OmniRoute எந்த Antigravity கிளையன்ட் அடையாளத்தை வழங்குகிறது என்பதைத் தேர்வுசெய்யவும்.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast அடுக்கு செயலில் உள்ளது", "tierFast": "வேகமான", "antigravityProjectIdLabel": "Google Cloud Project ஐடி", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 90d3deeb8b..85734d09df 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "క్లయింట్ ప్రొఫైల్", "antigravityClientProfileHint": "APIకి OmniRoute ఏ Antigravity క్లయింట్ గుర్తింపును ప్రదర్శించాలో ఎంచుకోండి.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast శ్రేణి సక్రియంగా ఉంది", "tierFast": "ఫాస్ట్", "antigravityProjectIdLabel": "Google క్లౌడ్ ప్రాజెక్ట్ ID", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 39741492f9..b65947d4d3 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "โปรไฟล์ไคลเอนต์", "antigravityClientProfileHint": "เลือกตัวตนไคลเอนต์ Antigravity ที่ OmniRoute จะแสดงต่อ API", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "ระดับ Codex Fast เปิดใช้งานอยู่", "tierFast": "เร็ว", "antigravityProjectIdLabel": "รหัสโครงการ Google Cloud", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 7cb7c5d954..fe69d22dbf 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "İstemci profili", "antigravityClientProfileHint": "OmniRoute'un API'ye hangi Antigravity istemci kimliğini sunacağını seçin.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast katmanı etkin", "tierFast": "Hızlı", "antigravityProjectIdLabel": "Google Cloud Proje Kimliği", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 5148d2b19c..204cd02b96 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "Профіль клієнта", "antigravityClientProfileHint": "Виберіть, яку клієнтську ідентичність Antigravity OmniRoute представляє API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast tier активний", "tierFast": "Швидкий", "antigravityProjectIdLabel": "Ідентифікатор проекту Google Cloud", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 34eab115db..75504aaafb 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "کلائنٹ پروفائل", "antigravityClientProfileHint": "منتخب کریں کہ OmniRoute، API کو کون سی Antigravity کلائنٹ شناخت پیش کرے۔", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "Harness / CLI", "codexFastTierActiveChip": "Codex Fast ٹیئر فعال ہے", "tierFast": "Fast", "antigravityProjectIdLabel": "گوگل کلاؤڈ پروجیکٹ ID", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 8cf4e4228b..a912a53e2a 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -5462,7 +5462,7 @@ "antigravityClientProfileLabel": "Hồ sơ ứng dụng khách", "antigravityClientProfileHint": "Chọn danh tính ứng dụng khách Antigravity mà OmniRoute sẽ sử dụng khi kết nối với API.", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "CLI", "codexFastTierActiveChip": "Gói Codex Fast đang hoạt động", "tierFast": "Nhanh", "antigravityProjectIdLabel": "Google Cloud Project ID", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 575dbe83a7..b99703910d 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -5460,7 +5460,7 @@ "antigravityClientProfileLabel": "客户端配置", "antigravityClientProfileHint": "选择 OmniRoute 向 API 呈现的 Antigravity 客户端身份。", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "CLI", "codexFastTierActiveChip": "Codex Fast 层级已启用", "tierFast": "快速", "antigravityProjectIdLabel": "谷歌云项目 ID", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 5cd24b4584..5453c39029 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -5302,7 +5302,7 @@ "antigravityClientProfileLabel": "客戶端配置", "antigravityClientProfileHint": "選擇 OmniRoute 向 API 呈現的 Antigravity 客戶端身份。", "antigravityClientProfileIde": "IDE", - "antigravityClientProfileHarness": "Harness / CLI", + "antigravityClientProfileCli": "CLI", "codexFastTierActiveChip": "Codex Fast 層級已啟用", "tierFast": "快速", "antigravityProjectIdLabel": "谷歌雲專案 ID", diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 6dad53b14d..92ec377cf7 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -91,10 +91,7 @@ export async function ensureDbReadyForBoot( // get the real root cause into stdout/app.log — without it, the // process keeps its HTTP listener up while every DB-touching route // 500s forever with a permanently empty log (#7773). - console.error( - "[STARTUP] Fatal: Database driver initialization failed:", - normalized.message - ); + console.error("[STARTUP] Fatal: Database driver initialization failed:", normalized.message); throw normalized; } console.warn( @@ -243,7 +240,9 @@ export async function registerNodejs(): Promise { await Promise.all([ import("@/lib/env/runtimeEnv").then(({ enforceWebRuntimeEnv }) => enforceWebRuntimeEnv()), import("@/lib/usage/migrations"), - import("@/lib/consoleInterceptor").then(({ initConsoleInterceptor }) => initConsoleInterceptor()), + import("@/lib/consoleInterceptor").then(({ initConsoleInterceptor }) => + initConsoleInterceptor() + ), ]); // Clear stale transient connection cooldowns persisted from an unclean crash. @@ -373,7 +372,7 @@ export async function registerNodejs(): Promise { const seededModelAliases = await seedDefaultModelAliases(); console.log( - `[STARTUP] Model alias seed: applied=${seededModelAliases.applied.length}, skipped=${seededModelAliases.skipped.length}, failed=${seededModelAliases.failed.length}` + `[STARTUP] Model alias seed: applied=${seededModelAliases.applied.length}, skipped=${seededModelAliases.skipped.length}, removed=${seededModelAliases.removed.length}, failed=${seededModelAliases.failed.length}` ); startSessionAccountAffinityCleanup(); @@ -455,21 +454,25 @@ export async function registerNodejs(): Promise { if (!isBackgroundServicesDisabled()) { // All services are independent — run in parallel for faster cold start. await Promise.allSettled([ - import("@/lib/services/bootstrap").then(async (m) => { - await m.bootstrapEmbeddedServices(); - console.log("[STARTUP] Embedded services bootstrap complete"); - }).catch((err: unknown) => { - const msg = err instanceof Error ? err.message : String(err); - console.warn("[STARTUP] Embedded services bootstrap failed (non-fatal):", msg); - }), + import("@/lib/services/bootstrap") + .then(async (m) => { + await m.bootstrapEmbeddedServices(); + console.log("[STARTUP] Embedded services bootstrap complete"); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[STARTUP] Embedded services bootstrap failed (non-fatal):", msg); + }), - import("@/lib/services/embedWsProxy").then((m) => m.initEmbedWsProxy()) + import("@/lib/services/embedWsProxy") + .then((m) => m.initEmbedWsProxy()) .catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); console.warn("[STARTUP] Embed WS proxy failed to start (non-fatal):", msg); }), - import("@omniroute/open-sse/services/autoRefreshDaemon").then((m) => m.autoRefreshDaemon.start()) + import("@omniroute/open-sse/services/autoRefreshDaemon") + .then((m) => m.autoRefreshDaemon.start()) .catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); console.warn("[STARTUP] Auto-refresh daemon failed to start (non-fatal):", msg); @@ -478,7 +481,8 @@ export async function registerNodejs(): Promise { // Proactive connection-cooldown recovery (#8): re-validate connections whose // transient `rate_limited_until` window has elapsed OUTSIDE the request hot path, // so the first request after a cooldown does not pay the probe latency. - import("@/lib/quota/connectionRecovery").then((m) => m.initConnectionRecoveryScheduler()) + import("@/lib/quota/connectionRecovery") + .then((m) => m.initConnectionRecoveryScheduler()) .catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); console.warn("[STARTUP] Connection recovery scheduler failed to start (non-fatal):", msg); @@ -486,17 +490,20 @@ export async function registerNodejs(): Promise { // Arena ELO sync: model intelligence from the Arena AI leaderboard, powering the // Free Provider Rankings page. On by default; non-blocking, never fatal. - import("@/lib/arenaEloSync").then(async (m) => { - const started = await m.initArenaEloSync(); - if (started) console.log("[STARTUP] Arena ELO sync initialized"); - }).catch((err: unknown) => { - const msg = err instanceof Error ? err.message : String(err); - console.warn("[STARTUP] Arena ELO sync failed to start (non-fatal):", msg); - }), + import("@/lib/arenaEloSync") + .then(async (m) => { + const started = await m.initArenaEloSync(); + if (started) console.log("[STARTUP] Arena ELO sync initialized"); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[STARTUP] Arena ELO sync failed to start (non-fatal):", msg); + }), // Pricing sync: opt-in external pricing data (self-gated by PRICING_SYNC_ENABLED inside // initPricingSync). Non-blocking, never fatal. - import("@/lib/pricingSync").then((m) => m.initPricingSync()) + import("@/lib/pricingSync") + .then((m) => m.initPricingSync()) .catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); console.warn("[STARTUP] Pricing sync failed to start (non-fatal):", msg); @@ -504,7 +511,8 @@ export async function registerNodejs(): Promise { // models.dev capability sync: opt-in via Settings > AI (self-gated by // settings.modelsDevSyncEnabled inside initModelsDevSync). Non-blocking, never fatal. - import("@/lib/modelsDevSync").then((m) => m.initModelsDevSync()) + import("@/lib/modelsDevSync") + .then((m) => m.initModelsDevSync()) .catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); console.warn("[STARTUP] models.dev sync failed to start (non-fatal):", msg); @@ -512,7 +520,8 @@ export async function registerNodejs(): Promise { // Context-window self-correction (5004): periodically reconcile provider-declared // windows (from /models discovery) into auto:discovery overrides. Never fatal. - import("@/lib/contextWindowResolver").then((m) => m.startContextWindowReconcile()) + import("@/lib/contextWindowResolver") + .then((m) => m.startContextWindowReconcile()) .catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); console.warn("[STARTUP] context-window reconcile failed to start (non-fatal):", msg); @@ -521,7 +530,8 @@ export async function registerNodejs(): Promise { // TV6 typed memory decay: optional periodic sweep of decayed episodic memories. // Doubly opt-in (no-op unless MEMORY_TYPED_DECAY_ENABLED=true AND // MEMORY_TYPED_DECAY_SWEEP_INTERVAL>0). Never deletes by default. Never fatal. - import("@/lib/memory/typedDecay").then((m) => m.startMemoryDecaySweep()) + import("@/lib/memory/typedDecay") + .then((m) => m.startMemoryDecaySweep()) .catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); console.warn("[STARTUP] memory decay sweep failed to start (non-fatal):", msg); @@ -530,12 +540,17 @@ export async function registerNodejs(): Promise { // Real-time dashboard WebSocket daemon (port 20132): powers Combo Studio Live, // the Home live-pulse, and Live Compression. Side-effect import triggers the // flag-gated auto-start (OMNIROUTE_ENABLE_LIVE_WS, default ON). - import("@/server/ws/liveServer").then(() => { - console.log("[STARTUP] Live dashboard WebSocket daemon bootstrap invoked"); - }).catch((err: unknown) => { - const msg = err instanceof Error ? err.message : String(err); - console.warn("[STARTUP] Live dashboard WebSocket daemon failed to start (non-fatal):", msg); - }), + import("@/server/ws/liveServer") + .then(() => { + console.log("[STARTUP] Live dashboard WebSocket daemon bootstrap invoked"); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.warn( + "[STARTUP] Live dashboard WebSocket daemon failed to start (non-fatal):", + msg + ); + }), ]); } diff --git a/src/lib/modelAliasSeed.ts b/src/lib/modelAliasSeed.ts index 287b55ac94..ed4b0c770f 100644 --- a/src/lib/modelAliasSeed.ts +++ b/src/lib/modelAliasSeed.ts @@ -1,12 +1,18 @@ -import { getModelAliases, setModelAlias } from "@/lib/db/models"; +import { deleteModelAlias, getModelAliases, setModelAlias } from "@/lib/db/models"; export const DEFAULT_MODEL_ALIAS_SEED = Object.freeze({ - "gemini-3-pro-high": "agy/gemini-3.1-pro-high", + "gemini-3.1-pro": "agy/gemini-pro-agent", + "gemini-3.1-flash-lite-preview": "gemini/gemini-3.1-flash-lite", +}); + +// Remove only aliases that still match a default value previously shipped by OmniRoute. +// User-customized targets with the same alias key are intentionally preserved. +export const RETIRED_DEFAULT_MODEL_ALIAS_SEED = Object.freeze({ + "gemini-3-pro-high": ["agy/gemini-3.1-pro-high", "agy/gemini-pro-agent"], "gemini-3-pro-low": "agy/gemini-3.1-pro-low", "gemini-3-pro-preview": "agy/gemini-pro-agent", "gemini-3.1-pro-preview": "agy/gemini-pro-agent", "gemini-3-flash-preview": "agy/gemini-3.5-flash-medium", - "gemini-3.1-flash-lite-preview": "gemini/gemini-3.1-flash-lite", }); type SeedLogger = { @@ -14,8 +20,10 @@ type SeedLogger = { }; type SeedOptions = { + deleteAlias?: typeof deleteModelAlias; getAliases?: typeof getModelAliases; logger?: SeedLogger; + retiredSeedMap?: Record; seedMap?: Record; setAlias?: typeof setModelAlias; }; @@ -23,6 +31,7 @@ type SeedOptions = { type SeedResult = { applied: string[]; failed: string[]; + removed: string[]; skipped: string[]; }; @@ -37,10 +46,18 @@ function isValidAliasTarget(value: unknown): boolean { ); } +function matchesRetiredAliasTarget(existingTarget: unknown, retiredTarget: unknown): boolean { + return Array.isArray(retiredTarget) + ? retiredTarget.includes(existingTarget) + : existingTarget === retiredTarget; +} + export async function seedDefaultModelAliases(options: SeedOptions = {}): Promise { const getAliases = options.getAliases || getModelAliases; const setAlias = options.setAlias || setModelAlias; + const deleteAlias = options.deleteAlias || deleteModelAlias; const seedMap = options.seedMap || DEFAULT_MODEL_ALIAS_SEED; + const retiredSeedMap = options.retiredSeedMap || RETIRED_DEFAULT_MODEL_ALIAS_SEED; const logger = options.logger || console; let existing: Record = {}; @@ -49,12 +66,25 @@ export async function seedDefaultModelAliases(options: SeedOptions = {}): Promis existing = loaded && typeof loaded === "object" ? loaded : {}; } catch (error) { logger.warn?.("[STARTUP] Failed to load model aliases before seed:", error); - return { applied: [], skipped: [], failed: Object.keys(seedMap) }; + return { applied: [], skipped: [], failed: Object.keys(seedMap), removed: [] }; } const applied: string[] = []; const skipped: string[] = []; const failed: string[] = []; + const removed: string[] = []; + + for (const [alias, retiredTarget] of Object.entries(retiredSeedMap)) { + if (!matchesRetiredAliasTarget(existing[alias], retiredTarget)) continue; + try { + await deleteAlias(alias); + delete existing[alias]; + removed.push(alias); + } catch (error) { + failed.push(alias); + logger.warn?.(`[STARTUP] Failed to remove retired model alias seed "${alias}":`, error); + } + } for (const [alias, target] of Object.entries(seedMap)) { if (!alias || !isValidAliasTarget(target)) { @@ -78,5 +108,5 @@ export async function seedDefaultModelAliases(options: SeedOptions = {}): Promis } } - return { applied, skipped, failed }; + return { applied, skipped, failed, removed }; } diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 990fc139a5..1fa5a9f3dc 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -1,12 +1,8 @@ import { - ANTIGRAVITY_BASE_URLS, + ANTIGRAVITY_BOOTSTRAP_BASE_URLS, + ANTIGRAVITY_RUNTIME_BASE_URLS, getAntigravityFetchAvailableModelsUrls, } from "@omniroute/open-sse/config/antigravityUpstream.ts"; -import { - ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT, - ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT, - getAntigravityLoadCodeAssistClientMetadata, -} from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { GITHUB_COPILOT_API_VERSION, GITHUB_COPILOT_CHAT_PLUGIN_VERSION, @@ -199,19 +195,18 @@ export const ANTIGRAVITY_CONFIG = { "https://www.googleapis.com/auth/experimentsandconfigs", ], // Antigravity specific - apiEndpoint: ANTIGRAVITY_BASE_URLS[0], + apiEndpoint: ANTIGRAVITY_RUNTIME_BASE_URLS[0], apiVersion: "v1internal", - loadCodeAssistEndpoints: ANTIGRAVITY_BASE_URLS.map( + loadCodeAssistEndpoints: ANTIGRAVITY_BOOTSTRAP_BASE_URLS.map( (baseUrl) => `${baseUrl}/v1internal:loadCodeAssist` ), - onboardUserEndpoints: ANTIGRAVITY_BASE_URLS.map((baseUrl) => `${baseUrl}/v1internal:onboardUser`), + onboardUserEndpoints: ANTIGRAVITY_BOOTSTRAP_BASE_URLS.map( + (baseUrl) => `${baseUrl}/v1internal:onboardUser` + ), fetchAvailableModelsEndpoints: getAntigravityFetchAvailableModelsUrls(), - loadCodeAssistEndpoint: `${ANTIGRAVITY_BASE_URLS[0]}/v1internal:loadCodeAssist`, - onboardUserEndpoint: `${ANTIGRAVITY_BASE_URLS[0]}/v1internal:onboardUser`, + loadCodeAssistEndpoint: `${ANTIGRAVITY_BOOTSTRAP_BASE_URLS[0]}/v1internal:loadCodeAssist`, + onboardUserEndpoint: `${ANTIGRAVITY_BOOTSTRAP_BASE_URLS[0]}/v1internal:onboardUser`, fetchAvailableModelsEndpoint: getAntigravityFetchAvailableModelsUrls()[0], - loadCodeAssistUserAgent: ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT, - loadCodeAssistApiClient: ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT, - loadCodeAssistClientMetadata: getAntigravityLoadCodeAssistClientMetadata(), }; // Antigravity CLI (`agy`) OAuth Configuration. @@ -237,9 +232,6 @@ export const AGY_CONFIG = { loadCodeAssistEndpoint: ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, onboardUserEndpoint: ANTIGRAVITY_CONFIG.onboardUserEndpoint, fetchAvailableModelsEndpoint: ANTIGRAVITY_CONFIG.fetchAvailableModelsEndpoint, - loadCodeAssistUserAgent: ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent, - loadCodeAssistApiClient: ANTIGRAVITY_CONFIG.loadCodeAssistApiClient, - loadCodeAssistClientMetadata: ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata, }; // OpenAI OAuth Configuration (Authorization Code Flow with PKCE) diff --git a/src/lib/oauth/providers/agy.ts b/src/lib/oauth/providers/agy.ts index 3ff2caed91..90f0f98ee8 100644 --- a/src/lib/oauth/providers/agy.ts +++ b/src/lib/oauth/providers/agy.ts @@ -1,15 +1,5 @@ -import { antigravity } from "./antigravity"; import { AGY_CONFIG } from "../constants/oauth"; +import { createAntigravityOAuthProvider } from "./antigravity"; -/** - * Antigravity CLI (`agy`) OAuth provider module. - * - * `agy` targets the identical Google consumer-OAuth backend as the `antigravity` provider - * (same client_id, scopes, token URL and Code Assist endpoints — verified byte-for-byte), - * so it reuses the antigravity authorization-code + PKCE flow (`buildAuthUrl`, - * `exchangeToken`, `postExchange`, `mapTokens`) wholesale. Only the `config` label differs; - * `AGY_CONFIG` resolves to the same embedded antigravity credentials. Kept as its own - * module so `agy` is a first-class, standalone entry in the OAuth provider registry and can - * diverge later without touching the antigravity flow. - */ -export const agy = { ...antigravity, config: AGY_CONFIG }; +/** Official Antigravity CLI OAuth flow with an explicit CLI client identity. */ +export const agy = createAntigravityOAuthProvider(AGY_CONFIG, "cli"); diff --git a/src/lib/oauth/providers/antigravity.ts b/src/lib/oauth/providers/antigravity.ts index 9128c62845..8456938f82 100644 --- a/src/lib/oauth/providers/antigravity.ts +++ b/src/lib/oauth/providers/antigravity.ts @@ -1,22 +1,30 @@ import { ANTIGRAVITY_CONFIG } from "../constants/oauth"; +import type { AntigravityClientProfile } from "@/shared/constants/antigravityClientProfile"; import { - antigravityNativeOAuthUserAgent, - getAntigravityHeaders, + getAntigravityContentHeaders, + getAntigravityIdeNodeHeaders, getAntigravityLoadCodeAssistMetadata, + getAntigravityOAuthUserAgent, } from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { extractCodeAssistOnboardTierId } from "@omniroute/open-sse/services/codeAssistSubscription.ts"; -// Bound every Antigravity post-exchange call. Without this an unreachable/slow -// upstream made the `/exchange` request (and therefore the whole OAuth login) -// hang forever — the dashboard "just spins". Mirrors the AbortSignal.timeout -// pattern already used by antigravityProjectBootstrap.ts. const POSTEXCHANGE_TIMEOUT_MS = 8_000; +type AntigravityOAuthConfig = typeof ANTIGRAVITY_CONFIG; +type AntigravityTokenPayload = { + access_token: string; + expires_in?: number; + refresh_token?: string; + scope?: string; +}; +type AntigravityPostExchange = { + projectId: string; + tierId: string; + userInfo: { email?: string }; +}; + async function fetchFirstOk(endpoints: string[], init: RequestInit, timeoutMs?: number) { let lastError: unknown = null; - // One shared deadline for the WHOLE fallback list — a stalled set of endpoints - // must bound to a single timeout, not timeoutMs × endpoints (that re-introduced - // a ~40s login wait). A reachable endpoint still returns immediately. const signal = timeoutMs ? AbortSignal.timeout(timeoutMs) : init.signal; for (const endpoint of endpoints) { try { @@ -30,148 +38,156 @@ async function fetchFirstOk(endpoints: string[], init: RequestInit, timeoutMs?: throw lastError || new Error("No Antigravity endpoints configured"); } -export const antigravity = { - config: ANTIGRAVITY_CONFIG, - // NO PKCE. The embedded Antigravity client is a Google "Desktop/native" OAuth client; - // sending a PKCE code_challenge (combined with the openid scope) pushed Google into the - // `signin/oauth/firstparty/nativeapp` consent flow that hangs and never redirects back - // (operator report 2026-06-27). The working 9router flow uses a plain authorization_code - // grant with client_secret and no code_challenge — match it exactly. The token exchange - // already sends client_secret (ANTIGRAVITY_OAUTH_CLIENT_SECRET) and omits code_verifier. - flowType: "authorization_code", - buildAuthUrl: (config, redirectUri, state, codeChallenge) => { - const params = new URLSearchParams({ - client_id: config.clientId, - response_type: "code", - redirect_uri: redirectUri, - scope: config.scopes.join(" "), - state: state, - access_type: "offline", - prompt: "consent", - }); - if (codeChallenge) { - params.set("code_challenge", codeChallenge); - params.set("code_challenge_method", "S256"); - } - return `${config.authorizeUrl}?${params.toString()}`; - }, - // NOTE: no PKCE. Antigravity is a plain authorization_code grant now (see flowType - // above). The shared generateAuthData() still mints a codeVerifier for every flow, but - // we MUST NOT forward it here — the authorize URL carries no code_challenge, so sending - // a code_verifier makes Google reject the exchange with invalid_grant ("code_verifier - // provided but code_challenge was not"), surfacing as a 500 on /exchange. Ignore it and - // authenticate with client_secret only, exactly like the working 9router flow. - exchangeToken: async (config, code, redirectUri) => { - const bodyParams: Record = { - grant_type: "authorization_code", - client_id: config.clientId, - code: code, - redirect_uri: redirectUri, - }; +function getPostExchangeHeaders( + profile: AntigravityClientProfile, + accessToken: string +): Record { + return profile === "cli" + ? getAntigravityContentHeaders("cli", accessToken) + : getAntigravityIdeNodeHeaders(accessToken); +} - if (config.clientSecret) { - bodyParams.client_secret = config.clientSecret; - } +function buildAntigravityAuthUrl( + config: AntigravityOAuthConfig, + redirectUri: string, + state: string, + codeChallenge?: string +): string { + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: config.scopes.join(" "), + state, + access_type: "offline", + prompt: "consent", + }); + if (codeChallenge) { + params.set("code_challenge", codeChallenge); + params.set("code_challenge_method", "S256"); + } + return `${config.authorizeUrl}?${params.toString()}`; +} - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - "User-Agent": antigravityNativeOAuthUserAgent(), - }, - body: new URLSearchParams(bodyParams), - }); +async function exchangeAntigravityToken( + config: AntigravityOAuthConfig, + clientProfile: AntigravityClientProfile, + code: string, + redirectUri: string +): Promise { + const bodyParams: Record = { + grant_type: "authorization_code", + client_id: config.clientId, + code, + redirect_uri: redirectUri, + }; + if (config.clientSecret) bodyParams.client_secret = config.clientSecret; - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": getAntigravityOAuthUserAgent(clientProfile), + }, + body: new URLSearchParams(bodyParams), + }); + if (!response.ok) { + throw new Error(`Token exchange failed: ${await response.text()}`); + } + return (await response.json()) as AntigravityTokenPayload; +} - return await response.json(); - }, - postExchange: async (tokens) => { - const headers = getAntigravityHeaders("loadCodeAssist", tokens.access_token); - const metadata = getAntigravityLoadCodeAssistMetadata(); +function extractProjectId(data: Record): string { + const project = data.cloudaicompanionProject; + if (typeof project === "string") return project; + if (!project || typeof project !== "object" || Array.isArray(project)) return ""; + const id = (project as Record).id; + return typeof id === "string" ? id : ""; +} - // Best-effort + bounded: a slow userinfo endpoint must never hang the login. - const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, { - headers: { Authorization: `Bearer ${tokens.access_token}` }, - signal: AbortSignal.timeout(POSTEXCHANGE_TIMEOUT_MS), - }).catch(() => null); - const userInfo = userInfoRes?.ok ? await userInfoRes.json() : {}; - - let projectId = ""; - let tierId = "legacy-tier"; +async function onboardAntigravityUser( + config: AntigravityOAuthConfig, + headers: Record, + tierId: string, + metadata: Record +): Promise { + for (let i = 0; i < 10; i++) { try { - const loadRes = await fetchFirstOk( - ANTIGRAVITY_CONFIG.loadCodeAssistEndpoints, + const response = await fetchFirstOk( + config.onboardUserEndpoints, + { method: "POST", headers, body: JSON.stringify({ tier_id: tierId, metadata }) }, + POSTEXCHANGE_TIMEOUT_MS + ); + const result = (await response.json()) as { done?: boolean }; + if (result.done === true) return; + } catch { + return; + } + await new Promise((resolve) => setTimeout(resolve, 5000)); + } +} + +async function postExchangeAntigravity( + config: AntigravityOAuthConfig, + clientProfile: AntigravityClientProfile, + tokens: AntigravityTokenPayload +): Promise { + const headers = getPostExchangeHeaders(clientProfile, tokens.access_token); + const metadata = getAntigravityLoadCodeAssistMetadata(); + const userInfoResponse = await fetch(`${config.userInfoUrl}?alt=json`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + signal: AbortSignal.timeout(POSTEXCHANGE_TIMEOUT_MS), + }).catch(() => null); + const userInfo = userInfoResponse?.ok + ? ((await userInfoResponse.json()) as { email?: string }) + : {}; + + let projectId = ""; + let tierId = "legacy-tier"; + try { + const response = await fetchFirstOk( + config.loadCodeAssistEndpoints, + { method: "POST", headers, body: JSON.stringify({ metadata }) }, + POSTEXCHANGE_TIMEOUT_MS + ); + const data = (await response.json()) as Record; + projectId = extractProjectId(data); + tierId = extractCodeAssistOnboardTierId(data); + } catch (error) { + console.log("Failed to load code assist:", error); + } + + if (projectId) { + void onboardAntigravityUser(config, headers, tierId, metadata).catch(() => {}); + } else if (config.onboardUserEndpoints.length > 0) { + // Accounts without an existing Cloud Code project need one bounded inline + // onboarding attempt before loadCodeAssist can discover their project. + try { + await fetchFirstOk( + config.onboardUserEndpoints, + { method: "POST", headers, body: JSON.stringify({ tier_id: tierId, metadata }) }, + POSTEXCHANGE_TIMEOUT_MS + ); + const retryResponse = await fetchFirstOk( + config.loadCodeAssistEndpoints, { method: "POST", headers, body: JSON.stringify({ metadata }) }, POSTEXCHANGE_TIMEOUT_MS ); - const data = await loadRes.json(); - projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; - tierId = extractCodeAssistOnboardTierId(data); - } catch (e) { - console.log("Failed to load code assist:", e); + projectId = extractProjectId((await retryResponse.json()) as Record); + } catch { + // Lazy request-time bootstrap retries if onboarding or discovery is unavailable. } + } + return { userInfo, projectId, tierId }; +} - if (projectId) { - // Fire-and-forget onboarding — must NOT block the OAuth login response. - // The previous inline `await` loop (up to 10×5s, each fetch un-timed) made the - // `/exchange` request hang forever when an upstream was slow/unreachable, so the - // dashboard "just spun". Onboarding is also performed lazily at request time by - // antigravityProjectBootstrap.ts, so backgrounding it here is safe. Matches the - // 9router web flow. (#5180-followup / antigravity login hang) - const onboardInBackground = async () => { - for (let i = 0; i < 10; i++) { - try { - const onboardRes = await fetchFirstOk( - ANTIGRAVITY_CONFIG.onboardUserEndpoints, - { method: "POST", headers, body: JSON.stringify({ tier_id: tierId, metadata }) }, - POSTEXCHANGE_TIMEOUT_MS - ); - const result = await onboardRes.json(); - if (result.done === true) break; - } catch { - break; - } - await new Promise((resolve) => setTimeout(resolve, 5000)); - } - }; - void onboardInBackground().catch(() => {}); - } else if (ANTIGRAVITY_CONFIG.onboardUserEndpoints.length > 0) { - // No projectId — the account has no pre-existing Cloud Code project. - // Attempt onboarding inline (bounded by timeout) so the project gets created - // and discovered within the same login flow. If onboarding succeeds, retry - // loadCodeAssist to pick up the newly created projectId. - // (#5193 regression of #2541 — catch-22: onboarding was never attempted) - try { - await fetchFirstOk( - ANTIGRAVITY_CONFIG.onboardUserEndpoints, - { method: "POST", headers, body: JSON.stringify({ tier_id: tierId, metadata }) }, - POSTEXCHANGE_TIMEOUT_MS - ); - // Onboarding succeeded (or at least the endpoint responded ok). Retry - // loadCodeAssist to discover the newly created project. - const retryRes = await fetchFirstOk( - ANTIGRAVITY_CONFIG.loadCodeAssistEndpoints, - { method: "POST", headers, body: JSON.stringify({ metadata }) }, - POSTEXCHANGE_TIMEOUT_MS - ); - const retryData = await retryRes.json(); - projectId = - retryData.cloudaicompanionProject?.id || retryData.cloudaicompanionProject || ""; - } catch { - // Onboarding or retry failed/timed out — graceful degradation. - // projectId stays empty. Lazy retry at request-time by - // antigravityProjectBootstrap.ts will handle it later. - } - } - - return { userInfo, projectId, tierId }; - }, - mapTokens: (tokens, extra) => ({ +function mapAntigravityTokens( + clientProfile: AntigravityClientProfile, + tokens: AntigravityTokenPayload, + extra?: AntigravityPostExchange +) { + return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresIn: tokens.expires_in, @@ -179,8 +195,26 @@ export const antigravity = { email: extra?.userInfo?.email, projectId: extra?.projectId, providerSpecificData: { + clientProfile, projectId: extra?.projectId, tier: extra?.tierId, }, - }), -}; + }; +} + +export function createAntigravityOAuthProvider( + config: AntigravityOAuthConfig, + clientProfile: AntigravityClientProfile +) { + return { + config, + flowType: "authorization_code" as const, + buildAuthUrl: buildAntigravityAuthUrl, + exchangeToken: (runtimeConfig, code, redirectUri) => + exchangeAntigravityToken(runtimeConfig, clientProfile, code, redirectUri), + postExchange: (tokens) => postExchangeAntigravity(config, clientProfile, tokens), + mapTokens: (tokens, extra) => mapAntigravityTokens(clientProfile, tokens, extra), + }; +} + +export const antigravity = createAntigravityOAuthProvider(ANTIGRAVITY_CONFIG, "ide"); diff --git a/src/lib/oauth/utils/agyAuthImport.ts b/src/lib/oauth/utils/agyAuthImport.ts index cd947c2ec5..77ea09c5c4 100644 --- a/src/lib/oauth/utils/agyAuthImport.ts +++ b/src/lib/oauth/utils/agyAuthImport.ts @@ -5,7 +5,7 @@ import { } from "@/lib/localDb"; import { AGY_CONFIG } from "@/lib/oauth/constants/oauth"; import { - getAntigravityHeaders, + getAntigravityContentHeaders, getAntigravityLoadCodeAssistMetadata, } from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { extractCodeAssistOnboardTierId } from "@omniroute/open-sse/services/codeAssistSubscription.ts"; @@ -140,7 +140,7 @@ export async function enrichWithAntigravityBackend( const loadController = new AbortController(); const loadTimer = setTimeout(() => loadController.abort(), 8000); try { - const headers = getAntigravityHeaders("loadCodeAssist", parsed.accessToken); + const headers = getAntigravityContentHeaders("cli", parsed.accessToken); const metadata = getAntigravityLoadCodeAssistMetadata(); for (const endpoint of AGY_CONFIG.loadCodeAssistEndpoints) { try { @@ -216,6 +216,7 @@ export async function createConnectionFromAgyToken( testStatus: "active", providerSpecificData: { ...toRecord(existing.providerSpecificData), + clientProfile: "cli", tokenType: enriched.tokenType, authMethod: enriched.authMethod, projectId: enriched.projectId ?? toRecord(existing.providerSpecificData).projectId, @@ -247,6 +248,7 @@ export async function createConnectionFromAgyToken( isActive: true, testStatus: "active", providerSpecificData: { + clientProfile: "cli", tokenType: enriched.tokenType, authMethod: enriched.authMethod, projectId: enriched.projectId, diff --git a/src/lib/usage/fetcher.ts b/src/lib/usage/fetcher.ts index cdac5e8195..7f937b0b5e 100644 --- a/src/lib/usage/fetcher.ts +++ b/src/lib/usage/fetcher.ts @@ -6,11 +6,14 @@ import { getGitHubCopilotInternalUserHeaders, getKiroServiceHeaders, } from "@omniroute/open-sse/config/providerHeaderProfiles.ts"; -import { applyAntigravityClientProfileHeaders } from "@omniroute/open-sse/services/antigravityClientProfile.ts"; -import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; +import { + applyAntigravityClientProfileHeaders, + getAntigravityClientProfile, +} from "@omniroute/open-sse/services/antigravityClientProfile.ts"; +import { getAntigravityContentHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { getAntigravityFetchAvailableModelsUrls, - ANTIGRAVITY_BASE_URLS, + ANTIGRAVITY_RUNTIME_BASE_URLS, } from "@omniroute/open-sse/config/antigravityUpstream.ts"; import { getAntigravityRemainingCredits, @@ -149,7 +152,7 @@ async function probeAntigravityCreditBalance( try { if (!projectId) return null; // Can't call streamGenerateContent without a projectId - const baseUrl = ANTIGRAVITY_BASE_URLS[0]; + const baseUrl = ANTIGRAVITY_RUNTIME_BASE_URLS[0]; const url = `${baseUrl}/v1internal:streamGenerateContent?alt=sse`; const body = { @@ -228,8 +231,8 @@ async function probeAntigravityCreditBalance( * Calls fetchAvailableModels to get per-model quota fractions. * Credit balance (GOOGLE_ONE_AI) is read from the executor's in-memory cache, * which is populated automatically after each successful credit-injected SSE call. - * If the cache is empty and credits mode is enabled, fires a minimal probe request - * to fetch the balance proactively. + * If the cache is empty and credits mode is `always`, fires a minimal probe request + * to fetch the balance proactively. `retry` mode never probes from the dashboard. */ async function getAntigravityUsage( accessToken: string, @@ -238,15 +241,17 @@ async function getAntigravityUsage( connectionId?: string | null ) { try { + const clientProfile = getAntigravityClientProfile({ providerSpecificData }); // Use connectionId as the cache key — matches executor's credentials.connectionId const accountId: string = connectionId || "unknown"; // Read cached credit balance from executor module (populated from SSE remainingCredits) let creditBalance = getAntigravityRemainingCredits(accountId); - // If no cached balance and credits mode is enabled, fire a minimal probe + // Only always mode may proactively spend credits to discover the balance. + // Retry mode must wait for an eligible user request quota failure. const creditsMode = getCreditsMode(); - if (creditBalance === null && creditsMode !== "off") { + if (creditBalance === null && creditsMode === "always") { creditBalance = await probeAntigravityCreditBalance( accessToken, accountId, @@ -263,7 +268,7 @@ async function getAntigravityUsage( try { res = await fetch(endpoint, { method: "POST", - headers: getAntigravityHeaders("fetchAvailableModels", accessToken), + headers: getAntigravityContentHeaders(clientProfile, accessToken), body: JSON.stringify({}), signal: AbortSignal.timeout(15_000), }); diff --git a/src/shared/constants/antigravityClientProfile.ts b/src/shared/constants/antigravityClientProfile.ts index 1d811173fb..c9b72b8960 100644 --- a/src/shared/constants/antigravityClientProfile.ts +++ b/src/shared/constants/antigravityClientProfile.ts @@ -1,4 +1,4 @@ -export const ANTIGRAVITY_CLIENT_PROFILE_VALUES = ["ide", "harness"] as const; +export const ANTIGRAVITY_CLIENT_PROFILE_VALUES = ["ide", "cli"] as const; export type AntigravityClientProfile = (typeof ANTIGRAVITY_CLIENT_PROFILE_VALUES)[number]; @@ -8,20 +8,24 @@ export type AntigravityClientProfileSetting = AntigravityClientProfile; export const ANTIGRAVITY_CLIENT_PROFILE_OPTIONS: Array<{ value: AntigravityClientProfileSetting; - labelKey: "antigravityClientProfileIde" | "antigravityClientProfileHarness"; + labelKey: "antigravityClientProfileIde" | "antigravityClientProfileCli"; }> = [ { value: "ide", labelKey: "antigravityClientProfileIde" }, - { value: "harness", labelKey: "antigravityClientProfileHarness" }, + { value: "cli", labelKey: "antigravityClientProfileCli" }, ]; -function toNonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - export function normalizeAntigravityClientProfile(value: unknown): AntigravityClientProfile { - const normalized = toNonEmptyString(value)?.toLowerCase(); - if (normalized === "harness" || normalized === "cli" || normalized === "sdk") { - return "harness"; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "ide" || normalized === "cli") { + return normalized; + } + // Read-only compatibility for values persisted before the official CLI profile + // replaced OmniRoute's synthetic harness/sdk naming. New writes are validated + // against ANTIGRAVITY_CLIENT_PROFILE_VALUES and cannot reintroduce these aliases. + if (normalized === "harness" || normalized === "sdk") { + return "cli"; + } } return DEFAULT_ANTIGRAVITY_CLIENT_PROFILE; } diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index a1dd3f7a08..f2b58cd528 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -4,6 +4,8 @@ import type { CliCatalogEntry } from "@/shared/schemas/cliCatalog"; import { GROK_BUILD_CLI_TOOL } from "@/shared/constants/cliToolsGrokBuild"; const _cc = getClaudeCodeDefaultModels(); +type CliModel = NonNullable[number]; +const createCliModel = (id: string, name: string): CliModel => ({ id, name, alias: id }); export const CLI_TOOLS: Record = { claude: { @@ -217,28 +219,30 @@ export const CLI_TOOLS: Record = { acpSpawnable: false, baseUrlSupport: "none", modelAliases: [ + "gemini-3.6-flash-high", + "gemini-3.6-flash-medium", + "gemini-3.6-flash-low", "claude-opus-4-6-thinking", "claude-sonnet-4-6", - "gemini-3-flash", - "gpt-oss-120b-medium", - "gemini-3.1-pro-high", + "gemini-pro-agent", "gemini-3.1-pro-low", + "gemini-3-flash-agent", + "gemini-3.5-flash-low", + "gemini-3.5-flash-extra-low", + "gpt-oss-120b-medium", ], defaultModels: [ - { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", alias: "gemini-3.1-pro-high" }, - { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low", alias: "gemini-3.1-pro-low" }, - { id: "gemini-3-flash", name: "Gemini 3 Flash", alias: "gemini-3-flash" }, - { - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - alias: "claude-sonnet-4-6", - }, - { - id: "claude-opus-4-6-thinking", - name: "Claude Opus 4.6 Thinking", - alias: "claude-opus-4-6-thinking", - }, - { id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium", alias: "gpt-oss-120b-medium" }, + createCliModel("gemini-3.6-flash-high", "Gemini 3.6 Flash High"), + createCliModel("gemini-3.6-flash-medium", "Gemini 3.6 Flash Medium"), + createCliModel("gemini-3.6-flash-low", "Gemini 3.6 Flash Low"), + createCliModel("gemini-pro-agent", "Gemini 3.1 Pro High"), + createCliModel("gemini-3.1-pro-low", "Gemini 3.1 Pro Low"), + createCliModel("gemini-3-flash-agent", "Gemini 3.5 Flash High"), + createCliModel("gemini-3.5-flash-low", "Gemini 3.5 Flash Medium"), + createCliModel("gemini-3.5-flash-extra-low", "Gemini 3.5 Flash Low"), + createCliModel("claude-sonnet-4-6", "Claude Sonnet 4.6"), + createCliModel("claude-opus-4-6-thinking", "Claude Opus 4.6 Thinking"), + createCliModel("gpt-oss-120b-medium", "GPT OSS 120B Medium"), ], }, copilot: { diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index 240bba7e77..a5a319e5e4 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -83,6 +83,14 @@ const GPT_5_6_MODEL_SPEC = { supportsVision: true, } satisfies ModelSpec; +const GEMINI_35_FLASH_MODEL_SPEC = { + maxOutputTokens: 65536, + contextWindow: 1048576, + supportsThinking: false, + supportsTools: true, + supportsVision: true, +} satisfies ModelSpec; + export const MODEL_SPECS: Record = { "gpt-5.6": { ...GPT_5_6_MODEL_SPEC, @@ -147,13 +155,17 @@ export const MODEL_SPECS: Record = { supportsTools: true, supportsVision: true, }, - "gemini-3.5-flash-low": { - maxOutputTokens: 65536, - contextWindow: 1048576, - supportsThinking: false, - supportsTools: true, - supportsVision: true, - }, + "gemini-3.5-flash-extra-low": { ...GEMINI_35_FLASH_MODEL_SPEC }, + "gemini-3.5-flash-low": { ...GEMINI_35_FLASH_MODEL_SPEC }, + "gemini-3-flash-agent": { ...GEMINI_35_FLASH_MODEL_SPEC }, + + // ── Gemini 3.6 Flash (Antigravity live tiers) ─────────────────── + // The model id itself selects the upstream 10k/4k/1k reasoning tier. Antigravity + // still rejects client-supplied thinking parameters, so keep the explicit-parameter + // capability aligned with the existing Gemini 3.5 tier ids. + "gemini-3.6-flash-high": { ...GEMINI_35_FLASH_MODEL_SPEC }, + "gemini-3.6-flash-medium": { ...GEMINI_35_FLASH_MODEL_SPEC }, + "gemini-3.6-flash-low": { ...GEMINI_35_FLASH_MODEL_SPEC }, // ── Gemini 3 Flash series ─────────────────────────────────────── "gemini-3-flash": { @@ -178,6 +190,7 @@ export const MODEL_SPECS: Record = { supportsTools: true, supportsVision: true, aliases: [ + "gemini-pro-agent", "gemini-3.1-pro-high", "gemini-3-pro-high", "gemini-3-pro-preview", @@ -200,11 +213,7 @@ export const MODEL_SPECS: Record = { // ── Gemini 3.5 Flash ───────────────────────────────────────────── "gemini-3.5-flash": { - maxOutputTokens: 65536, - contextWindow: 1048576, - supportsThinking: false, - supportsTools: true, - supportsVision: true, + ...GEMINI_35_FLASH_MODEL_SPEC, aliases: ["gemini-3.5-flash-high"], }, diff --git a/src/shared/constants/pricing/oauth-subscriptions.ts b/src/shared/constants/pricing/oauth-subscriptions.ts index 6856f78402..694b6ced05 100644 --- a/src/shared/constants/pricing/oauth-subscriptions.ts +++ b/src/shared/constants/pricing/oauth-subscriptions.ts @@ -275,27 +275,20 @@ export const DEFAULT_PRICING_OAUTH = { reasoning: 18.0, cache_creation: 2.0, }, - "gemini-3.1-pro-high": { - input: 4.0, - output: 18.0, - cached: 0.5, - reasoning: 27.0, - cache_creation: 4.0, - }, - "gemini-3-flash": { + // Antigravity 2.0.4+ exposes Gemini 3.5 Flash as three public client ids + // (see ANTIGRAVITY_PUBLIC_MODELS in open-sse/config/antigravityModelAliases.ts): + // gemini-3.5-flash-extra-low → "Gemini 3.5 Flash (Low)" + // gemini-3-flash-agent → "Gemini 3.5 Flash (High)" + // gemini-3.5-flash-low → "Gemini 3.5 Flash (Medium)" + // Without these rows, getPricingForModel("ag", id) returns null and downstream + // cost and quota calculations silently fall back to $0. + "gemini-3.5-flash-extra-low": { input: 0.5, output: 3.0, cached: 0.03, reasoning: 4.5, cache_creation: 0.5, }, - // Antigravity 2.0.4+ exposes Gemini 3.5 Flash as three public client ids - // (see ANTIGRAVITY_PUBLIC_MODELS in open-sse/config/antigravityModelAliases.ts): - // gemini-3-flash-agent → "Gemini 3.5 Flash (High)" - // gemini-3.5-flash-low → "Gemini 3.5 Flash (Medium)" - // Both bill at the same per-MTok rates as legacy `gemini-3-flash` above — - // without these rows, getPricingForModel("ag", id) returned null and downstream - // cost / quota calculations silently fell back to $0. "gemini-3-flash-agent": { input: 0.5, output: 3.0, @@ -310,8 +303,7 @@ export const DEFAULT_PRICING_OAUTH = { reasoning: 4.5, cache_creation: 0.5, }, - // `gemini-pro-agent` is the Antigravity v1.23+ Agent-mode alias for the - // Gemini 3.1 Pro (High) tier — bills at the same rates as `gemini-3.1-pro-high`. + // `gemini-pro-agent` is the callable Antigravity id for Gemini 3.1 Pro (High). "gemini-pro-agent": { input: 4.0, output: 18.0, diff --git a/src/shared/validation/providerSpecificData.ts b/src/shared/validation/providerSpecificData.ts index 2838b019b5..54155523de 100644 --- a/src/shared/validation/providerSpecificData.ts +++ b/src/shared/validation/providerSpecificData.ts @@ -436,14 +436,10 @@ export function validateProviderSpecificData( const clientProfile = data.clientProfile; if (clientProfile !== undefined && clientProfile !== null) { const normalized = typeof clientProfile === "string" ? clientProfile.trim().toLowerCase() : ""; - if ( - typeof clientProfile !== "string" || - !["ide", "harness", "cli", "sdk"].includes(normalized) - ) { + if (typeof clientProfile !== "string" || !["ide", "cli"].includes(normalized)) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: - "providerSpecificData.clientProfile must be ide, harness, cli, or sdk (cli/sdk map to harness)", + message: "providerSpecificData.clientProfile must be ide or cli", path: ["clientProfile"], }); } diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index d4818131de..5ba707fd37 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -72,6 +72,10 @@ import { withSelectedConnectionHeader, withCorrelationId, } from "./chatHelpers"; +import { + isAntigravityMissingProjectError, + shouldTripProviderBreakerForResult, +} from "./chatPredicates"; import { connectionHasExtraKeys } from "@omniroute/open-sse/services/apiKeyRotator.ts"; import { extractReasoningIntent, @@ -84,7 +88,7 @@ import { filterReasoningCombo, } from "./reasoningRouting"; import { createVirtualAutoCombo, resolveAutoRoutingState } from "./autoRouting"; -import { getComboFailureLogError, isRequestScopedUpstreamFailure } from "./comboFailureLogging"; +import { getComboFailureLogError } from "./comboFailureLogging"; // Pipeline integration — wired modules import { classify429FromError, type FailureKind } from "@/shared/utils/classify429"; @@ -209,26 +213,9 @@ function intersectAllowedConnectionIds(primary: unknown, secondary: unknown): st return first || second || null; } -const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]); const comboPromoteDeps = { updateCombo, info: log.info, warn: log.warn }; -// #7907/#7908: single-model breaker trip bypasses the `isFailure` option (only applies -// inside `breaker.execute()`), so it needs its own `isLocalStreamLifecycleError` guard — -// otherwise a client abort (502 default, error='request_signal_aborted') trips the -// provider-wide breaker. Pure predicate, unit-testable without the full request path. -export function shouldTripProviderBreakerForResult( - result: { status: number; errorCode?: string | null; errorType?: string | null; error?: unknown }, - isCombo: boolean, - forceLiveComboTest: boolean -): boolean { - return ( - !forceLiveComboTest && - !isCombo && - !isRequestScopedUpstreamFailure({ code: result.errorCode, type: result.errorType }) && - !isLocalStreamLifecycleError(result.error) && - PROVIDER_BREAKER_FAILURE_STATUSES.has(Number(result.status)) - ); -} +export { shouldTripProviderBreakerForResult } from "./chatPredicates"; /** * Handle chat completion request @@ -1454,6 +1441,14 @@ async function handleSingleModelChat( return result.response; } + // Missing Cloud Code project assignment is an account configuration error, not a + // transient upstream/account failure. Preserve the executor's typed fail-closed 422; + // marking the connection unavailable here would trigger cooldown redispatch and repeat + // bootstrap within the same logical request. + if (isAntigravityMissingProjectError(provider, result)) { + return withSelectedConnectionHeader(result.response, credentials.connectionId); + } + const isAntigravityStreamReadinessFailure = provider === "antigravity" && (result.errorCode === "STREAM_READINESS_TIMEOUT" || diff --git a/src/sse/handlers/chatPredicates.ts b/src/sse/handlers/chatPredicates.ts new file mode 100644 index 0000000000..a45dbc4d5a --- /dev/null +++ b/src/sse/handlers/chatPredicates.ts @@ -0,0 +1,34 @@ +import { isLocalStreamLifecycleError } from "../../shared/utils/circuitBreaker"; +import { isRequestScopedUpstreamFailure } from "./comboFailureLogging"; + +const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]); + +// #7907/#7908: single-model breaker trip bypasses the `isFailure` option (only applies +// inside `breaker.execute()`), so it needs its own `isLocalStreamLifecycleError` guard — +// otherwise a client abort (502 default, error='request_signal_aborted') trips the +// provider-wide breaker. Pure predicate, unit-testable without the full request path. +export function shouldTripProviderBreakerForResult( + result: { status: number; errorCode?: string | null; errorType?: string | null; error?: unknown }, + isCombo: boolean, + forceLiveComboTest: boolean +): boolean { + return ( + !forceLiveComboTest && + !isCombo && + !isRequestScopedUpstreamFailure({ code: result.errorCode, type: result.errorType }) && + !isLocalStreamLifecycleError(result.error) && + PROVIDER_BREAKER_FAILURE_STATUSES.has(Number(result.status)) + ); +} + +export function isAntigravityMissingProjectError( + provider: string, + result: { status?: number; errorCode?: string; errorType?: string } +): boolean { + return ( + provider === "antigravity" && + result.status === 422 && + result.errorCode === "missing_project_id" && + result.errorType === "oauth_missing_project_id" + ); +} diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 1f685cc261..b7e8341cf2 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -24,6 +24,7 @@ import { isQuotaExhaustedForRequest, } from "@/domain/quotaCache"; import { getQuotaScopeLabelForProvider } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; +import { getCreditsMode } from "@omniroute/open-sse/services/antigravityCredits.ts"; import { isAccountUnavailable, getUnavailableUntil, @@ -1365,7 +1366,7 @@ export async function getProviderCredentials( return false; }); } else if (availableConnections.length > 0) { - log.debug("AUTH", `${provider} | bypassing quota policy for combo live test`); + log.debug("AUTH", `${provider} | bypassing cached quota policy for this request`); } if (blockedByPolicy.length > 0) { @@ -1678,13 +1679,22 @@ export async function getProviderCredentialsWithQuotaPreflight( requestedModel: string | null = null, options: CredentialSelectionOptions = {} ) { - if (options.bypassQuotaPolicy === true) { + // Credits-first requests intentionally skip both cached quota cutoffs and live + // usage preflight. The user inference itself is the one credit-bearing request; + // probing normal quota first would spend up to two extra credit calls. + const bypassQuotaPolicy = + options.bypassQuotaPolicy === true || + (provider === "antigravity" && getCreditsMode() === "always"); + if (bypassQuotaPolicy) { return getProviderCredentials( provider, excludeConnectionId, allowedConnections, requestedModel, - options + { + ...options, + bypassQuotaPolicy: true, + } ); } diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index de5d5fe6a3..55c86105ae 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -118,18 +118,18 @@ "Accept": "text/event-stream", "Authorization": "Bearer ", "Content-Type": "application/json", - "User-Agent": "Antigravity/4.2.0 () Chrome/142.0.7444.175 Electron/39.2.3" + "User-Agent": "antigravity/cli/1.1.5 (aidev_client; os_type=; arch=; auth_method=consumer)" }, "nonStream": { "Authorization": "Bearer ", "Content-Type": "application/json", - "User-Agent": "Antigravity/4.2.0 () Chrome/142.0.7444.175 Electron/39.2.3" + "User-Agent": "antigravity/cli/1.1.5 (aidev_client; os_type=; arch=; auth_method=consumer)" }, "oauth": { "Accept": "text/event-stream", "Authorization": "Bearer ", "Content-Type": "application/json", - "User-Agent": "Antigravity/4.2.0 () Chrome/142.0.7444.175 Electron/39.2.3" + "User-Agent": "antigravity/cli/1.1.5 (aidev_client; os_type=; arch=; auth_method=consumer)" } }, "url": { @@ -334,18 +334,18 @@ "Accept": "text/event-stream", "Authorization": "Bearer ", "Content-Type": "application/json", - "User-Agent": "Antigravity/4.2.0 () Chrome/142.0.7444.175 Electron/39.2.3" + "User-Agent": "antigravity/ide/2.1.1 /" }, "nonStream": { "Authorization": "Bearer ", "Content-Type": "application/json", - "User-Agent": "Antigravity/4.2.0 () Chrome/142.0.7444.175 Electron/39.2.3" + "User-Agent": "antigravity/ide/2.1.1 /" }, "oauth": { "Accept": "text/event-stream", "Authorization": "Bearer ", "Content-Type": "application/json", - "User-Agent": "Antigravity/4.2.0 () Chrome/142.0.7444.175 Electron/39.2.3" + "User-Agent": "antigravity/ide/2.1.1 /" } }, "url": { diff --git a/tests/unit/agy-gemini-3696-tier-passthrough.test.ts b/tests/unit/agy-gemini-3696-tier-passthrough.test.ts index d8bbad9a8b..53f70b5fbc 100644 --- a/tests/unit/agy-gemini-3696-tier-passthrough.test.ts +++ b/tests/unit/agy-gemini-3696-tier-passthrough.test.ts @@ -1,14 +1,6 @@ /** - * #3696 — antigravity/agy gemini-3.1-pro-high / gemini-3.1-pro-low were being collapsed - * to the bare upstream id `gemini-3.1-pro`, losing the tier distinction. - * - * Wire evidence (captured by maintainer via `agy --model gemini-3.1-pro-high --log-file`): - * - `gemini-3.1-pro-high` sent literally to `/v1internal:streamGenerateContent` → 200 OK - * - `gemini-3.1-pro-low` sent literally → 200 OK - * - * CONCLUSION: the upstream ACCEPTS the suffixed ids directly. The old assumption in #3229 - * ("upstream rejects the suffix for gemini-3.x") was refuted by this wire capture. - * The collapse aliases must be removed so the tier-specific ids reach the upstream. + * #3696 — the callable `gemini-3.1-pro-low` tier must reach Antigravity unchanged. + * High now uses the distinct live id `gemini-pro-agent`. */ import test from "node:test"; import assert from "node:assert/strict"; @@ -18,10 +10,6 @@ import { resolveAntigravityModelId, } from "../../open-sse/config/antigravityModelAliases.ts"; -test("(#3696) resolveAntigravityModelId passes gemini-3.1-pro-high through unchanged", () => { - assert.equal(resolveAntigravityModelId("gemini-3.1-pro-high"), "gemini-3.1-pro-high"); -}); - test("(#3696) resolveAntigravityModelId passes gemini-3.1-pro-low through unchanged", () => { assert.equal(resolveAntigravityModelId("gemini-3.1-pro-low"), "gemini-3.1-pro-low"); }); diff --git a/tests/unit/agy-gemini-400-3229.test.ts b/tests/unit/agy-gemini-400-3229.test.ts index 783db2257a..4dd0160742 100644 --- a/tests/unit/agy-gemini-400-3229.test.ts +++ b/tests/unit/agy-gemini-400-3229.test.ts @@ -1,13 +1,9 @@ /** - * #3229 — agy `gemini-3.1-pro-high`/`-low` returned [400] but were MASKED as an empty + * #3229 — agy upstream 400 responses were MASKED as an empty * `chat.completion` envelope. * * Two parts: - * (a) ORIGINAL FIX (#3229): aliased -high/-low to plain `gemini-3.1-pro` (upstream - * was believed to reject the suffix). SUPERSEDED BY #3696: wire capture via - * `agy --model gemini-3.1-pro-high --log-file` confirmed upstream returns 200 OK - * with the suffixed id; the collapse aliases are now removed so the tier-specific - * id passes through verbatim. + * (a) `gemini-3.1-pro-low` passes through verbatim; High uses `gemini-pro-agent`. * (b) the non-stream branch fed the 4xx response into the SSE collector, producing a * synthetic `{"object":"chat.completion","content":""}` instead of a real error → * build a proper sanitized error body for non-ok upstream responses. (Still valid.) @@ -18,10 +14,7 @@ import assert from "node:assert/strict"; import { resolveAntigravityModelId } from "../../open-sse/config/antigravityModelAliases.ts"; import { buildAntigravityUpstreamError } from "../../open-sse/executors/antigravityUpstreamError.ts"; -test("(a) agy gemini-3.1-pro -high/-low budget suffixes pass through to upstream unchanged (#3696)", () => { - // #3696: wire capture confirmed upstream accepts the suffixed ids verbatim. - // The old #3229 collapse aliases ("gemini-3.1-pro-high" → "gemini-3.1-pro") were removed. - assert.equal(resolveAntigravityModelId("gemini-3.1-pro-high"), "gemini-3.1-pro-high"); +test("(a) agy gemini-3.1-pro-low passes through to upstream unchanged (#3696)", () => { assert.equal(resolveAntigravityModelId("gemini-3.1-pro-low"), "gemini-3.1-pro-low"); // plain id stays plain assert.equal(resolveAntigravityModelId("gemini-3.1-pro"), "gemini-3.1-pro"); @@ -31,7 +24,7 @@ test("(b) a non-ok upstream response becomes a real error body, not an empty cha const body = buildAntigravityUpstreamError( 400, "Bad Request", - JSON.stringify({ error: { code: 400, message: "Model not found: gemini-3.1-pro-high" } }) + JSON.stringify({ error: { code: 400, message: "Model not found: rejected-model" } }) ); assert.notEqual((body as { object?: string }).object, "chat.completion"); assert.ok(body.error, "must carry an error object"); diff --git a/tests/unit/agy-pro-fallback-chain-3786.test.ts b/tests/unit/agy-pro-fallback-chain-3786.test.ts index 1762d6436a..7255a1140e 100644 --- a/tests/unit/agy-pro-fallback-chain-3786.test.ts +++ b/tests/unit/agy-pro-fallback-chain-3786.test.ts @@ -1,20 +1,7 @@ /** - * #3786 — Antigravity (`agy` / `antigravity`) `gemini-3.1-pro-high` returns HTTP 400 - * ("Antigravity upstream error (400)") on recent upstream versions; `gemini-3.1-pro-low` - * still works. OmniRoute sends the requested id VERBATIM (per #3696 wire capture). The - * upstream changed the accepted model-id format for the Pro-high tier and the two - * actively-maintained competitor proxies DISAGREE on the live id: - * - AntigravityManager → `gemini-3.1-pro-high` - * - CLIProxyAPI → `gemini-pro-agent` (display: "Gemini 3.1 Pro (High)") - * - older form → `gemini-3-pro-high` - * - * Because the live id cannot be known from static analysis, we mirror AntigravityManager's - * ROBUST approach: a per-request FALLBACK CHAIN that retries alternative upstream ids on a - * 400, until one succeeds (2xx) or the chain is exhausted (then the original 400 surfaces, - * sanitized — hard rule #12). - * - * The fallback chain lives at EXECUTOR REQUEST-TIME (retry on 400). It is NOT a change to - * the static `resolveAntigravityModelId` map, so the #3696 invariant test stays green. + * Antigravity Pro fallback behavior. The rejected `gemini-3.1-pro-high` discovery id is no + * longer public and has no fallback chain; the callable High id is `gemini-pro-agent`. + * Pro Low retains its bounded request-time fallback for older upstream versions. */ import test from "node:test"; import assert from "node:assert/strict"; @@ -24,7 +11,7 @@ import { getAntigravityModelFallbacks, } from "../../open-sse/config/antigravityModelAliases.ts"; import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts"; -import { seedAntigravityVersionCache } from "../../open-sse/services/antigravityVersion.ts"; +import { seedAntigravityIdeVersionCache } from "../../open-sse/services/antigravityVersion.ts"; type ChatCompletionPayload = { object?: string; @@ -39,12 +26,8 @@ type ErrorPayload = { // Pure helper: getAntigravityModelFallbacks // --------------------------------------------------------------------------- -test("(#3786) getAntigravityModelFallbacks returns the ordered pro-high chain", () => { - assert.deepEqual(getAntigravityModelFallbacks("gemini-3.1-pro-high"), [ - "gemini-3.1-pro-high", - "gemini-pro-agent", - "gemini-3-pro-high", - ]); +test("rejected pro-high discovery id has no fallback chain", () => { + assert.deepEqual(getAntigravityModelFallbacks("gemini-3.1-pro-high"), []); }); test("(#3786) getAntigravityModelFallbacks returns the ordered pro-low chain", () => { @@ -95,23 +78,22 @@ function envelopeModel(init: RequestInit | undefined): string { } } -test("(#3786) execute retries pro-high with the next candidate when the first id 400s", async () => { +test("execute retries pro-low with the next candidate when the first id 400s", async () => { const executor = new AntigravityExecutor(); const originalFetch = globalThis.fetch; - seedAntigravityVersionCache("2026.04.17-test"); + seedAntigravityIdeVersionCache("2.1.1"); const modelsTried: string[] = []; globalThis.fetch = (async (_url: string, init?: RequestInit) => { const m = envelopeModel(init); modelsTried.push(m); - // First candidate (gemini-3.1-pro-high) → 400, second (gemini-pro-agent) → 200 - if (m === "gemini-3.1-pro-high") return make400(m); + if (m === "gemini-3.1-pro-low") return make400(m); return makeSuccessSSE(); }) as typeof fetch; try { const result = await executor.execute({ - model: "antigravity/gemini-3.1-pro-high", + model: "antigravity/gemini-3.1-pro-low", body: { request: { contents: [] } }, stream: false, credentials: { accessToken: "token", projectId: "project-1" }, @@ -122,16 +104,16 @@ test("(#3786) execute retries pro-high with the next candidate when the first id assert.equal(result.response.status, 200, "second candidate should succeed"); assert.equal(payload.choices[0].message.content, "OK"); // Exactly two upstream calls: the 400 then the 200 on the next id. - assert.deepEqual(modelsTried, ["gemini-3.1-pro-high", "gemini-pro-agent"]); + assert.deepEqual(modelsTried, ["gemini-3.1-pro-low", "gemini-3-pro-low"]); } finally { globalThis.fetch = originalFetch; } }); -test("(#3786) execute exhausts the chain on all-400 and surfaces a sanitized 400 (each candidate tried once)", async () => { +test("execute exhausts the pro-low chain on all-400 and surfaces a sanitized 400", async () => { const executor = new AntigravityExecutor(); const originalFetch = globalThis.fetch; - seedAntigravityVersionCache("2026.04.17-test"); + seedAntigravityIdeVersionCache("2.1.1"); const modelsTried: string[] = []; globalThis.fetch = (async (_url: string, init?: RequestInit) => { @@ -142,7 +124,7 @@ test("(#3786) execute exhausts the chain on all-400 and surfaces a sanitized 400 try { const result = await executor.execute({ - model: "antigravity/gemini-3.1-pro-high", + model: "antigravity/gemini-3.1-pro-low", body: { request: { contents: [] } }, stream: false, credentials: { accessToken: "token", projectId: "project-1" }, @@ -157,16 +139,16 @@ test("(#3786) execute exhausts the chain on all-400 and surfaces a sanitized 400 assert.ok(!payload.error.message.includes("at /"), "no raw stack trace (hard rule #12)"); // Each candidate tried EXACTLY once (bounded — no infinite loop). - assert.deepEqual(modelsTried, ["gemini-3.1-pro-high", "gemini-pro-agent", "gemini-3-pro-high"]); + assert.deepEqual(modelsTried, ["gemini-3.1-pro-low", "gemini-3-pro-low"]); } finally { globalThis.fetch = originalFetch; } }); -test("(#3786) happy path: first id 200 makes exactly ONE upstream call (zero extra)", async () => { +test("pro-low happy path makes exactly one upstream call", async () => { const executor = new AntigravityExecutor(); const originalFetch = globalThis.fetch; - seedAntigravityVersionCache("2026.04.17-test"); + seedAntigravityIdeVersionCache("2.1.1"); const modelsTried: string[] = []; globalThis.fetch = (async (_url: string, init?: RequestInit) => { @@ -176,7 +158,7 @@ test("(#3786) happy path: first id 200 makes exactly ONE upstream call (zero ext try { const result = await executor.execute({ - model: "antigravity/gemini-3.1-pro-high", + model: "antigravity/gemini-3.1-pro-low", body: { request: { contents: [] } }, stream: false, credentials: { accessToken: "token", projectId: "project-1" }, @@ -184,7 +166,7 @@ test("(#3786) happy path: first id 200 makes exactly ONE upstream call (zero ext }); assert.equal(result.response.status, 200); - assert.deepEqual(modelsTried, ["gemini-3.1-pro-high"], "exactly one call on the happy path"); + assert.deepEqual(modelsTried, ["gemini-3.1-pro-low"], "exactly one call on the happy path"); } finally { globalThis.fetch = originalFetch; } @@ -197,19 +179,19 @@ test("(#3786) happy path: first id 200 makes exactly ONE upstream call (zero ext test("(#3786) exception on first candidate (timeout) falls through to second candidate returning 200", async () => { const executor = new AntigravityExecutor(); const originalFetch = globalThis.fetch; - seedAntigravityVersionCache("2026.04.17-test"); + seedAntigravityIdeVersionCache("2.1.1"); const modelsTried: string[] = []; globalThis.fetch = (async (_url: string, init?: RequestInit) => { const m = envelopeModel(init); modelsTried.push(m); - if (m === "gemini-3.1-pro-high") throw new Error("upstream timeout after 30000ms"); + if (m === "gemini-3.1-pro-low") throw new Error("upstream timeout after 30000ms"); return makeSuccessSSE(); }) as typeof fetch; try { const result = await executor.execute({ - model: "antigravity/gemini-3.1-pro-high", + model: "antigravity/gemini-3.1-pro-low", body: { request: { contents: [] } }, stream: false, credentials: { accessToken: "token", projectId: "project-1" }, @@ -220,10 +202,10 @@ test("(#3786) exception on first candidate (timeout) falls through to second can assert.equal(result.response.status, 200, "second candidate should succeed after first threw"); assert.equal(payload.choices[0].message.content, "OK"); // First candidate retried internally (URL-level retries on throw), then second succeeded. - assert.equal(modelsTried[0], "gemini-3.1-pro-high", "first call targets first candidate"); - assert.ok(modelsTried.includes("gemini-pro-agent"), "must eventually try second candidate"); + assert.equal(modelsTried[0], "gemini-3.1-pro-low", "first call targets first candidate"); + assert.ok(modelsTried.includes("gemini-3-pro-low"), "must eventually try second candidate"); assert.ok( - modelsTried.lastIndexOf("gemini-3.1-pro-high") < modelsTried.indexOf("gemini-pro-agent"), + modelsTried.lastIndexOf("gemini-3.1-pro-low") < modelsTried.indexOf("gemini-3-pro-low"), "first candidate exhausted before second tried" ); } finally { @@ -234,7 +216,7 @@ test("(#3786) exception on first candidate (timeout) falls through to second can test("(#3786) all candidates throw exceptions -- error includes 'chain exhausted'", async () => { const executor = new AntigravityExecutor(); const originalFetch = globalThis.fetch; - seedAntigravityVersionCache("2026.04.17-test"); + seedAntigravityIdeVersionCache("2.1.1"); const modelsTried: string[] = []; globalThis.fetch = (async (_url: string, init?: RequestInit) => { @@ -247,7 +229,7 @@ test("(#3786) all candidates throw exceptions -- error includes 'chain exhausted await assert.rejects( () => executor.execute({ - model: "antigravity/gemini-3.1-pro-high", + model: "antigravity/gemini-3.1-pro-low", body: { request: { contents: [] } }, stream: false, credentials: { accessToken: "token", projectId: "project-1" }, @@ -265,56 +247,12 @@ test("(#3786) all candidates throw exceptions -- error includes 'chain exhausted return true; } ); - // All 3 candidates tried (each retries internally on throw). - assert.ok(modelsTried.includes("gemini-3.1-pro-high"), "tried first candidate"); - assert.ok(modelsTried.includes("gemini-pro-agent"), "tried second candidate"); - assert.ok(modelsTried.includes("gemini-3-pro-high"), "tried third candidate"); - // Ordering: all first-candidate attempts before second, etc. - const lastFirst = modelsTried.lastIndexOf("gemini-3.1-pro-high"); - const firstSecond = modelsTried.indexOf("gemini-pro-agent"); - const lastSecond = modelsTried.lastIndexOf("gemini-pro-agent"); - const firstThird = modelsTried.indexOf("gemini-3-pro-high"); + // Both candidates are tried, with all first-candidate retries completed before the second. + assert.ok(modelsTried.includes("gemini-3.1-pro-low"), "tried first candidate"); + assert.ok(modelsTried.includes("gemini-3-pro-low"), "tried second candidate"); + const lastFirst = modelsTried.lastIndexOf("gemini-3.1-pro-low"); + const firstSecond = modelsTried.indexOf("gemini-3-pro-low"); assert.ok(lastFirst < firstSecond, "first candidate exhausted before second tried"); - assert.ok(lastSecond < firstThird, "second candidate exhausted before third tried"); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("(#3786) mixed path: 400 on first, exception on second, 200 on third", async () => { - const executor = new AntigravityExecutor(); - const originalFetch = globalThis.fetch; - seedAntigravityVersionCache("2026.04.17-test"); - const modelsTried: string[] = []; - - globalThis.fetch = (async (_url: string, init?: RequestInit) => { - const m = envelopeModel(init); - modelsTried.push(m); - if (m === "gemini-3.1-pro-high") return make400(m); - if (m === "gemini-pro-agent") throw new Error("connection reset"); - return makeSuccessSSE(); - }) as typeof fetch; - - try { - const result = await executor.execute({ - model: "antigravity/gemini-3.1-pro-high", - body: { request: { contents: [] } }, - stream: false, - credentials: { accessToken: "token", projectId: "project-1" }, - log: { debug() {}, warn() {}, info() {} }, - }); - const payload = (await result.response.json()) as ChatCompletionPayload; - - assert.equal(result.response.status, 200, "third candidate should succeed"); - assert.equal(payload.choices[0].message.content, "OK"); - // First candidate (400, no retry) -> second candidate (throws, retried internally) -> third (200). - assert.equal(modelsTried[0], "gemini-3.1-pro-high", "first call targets first candidate"); - assert.ok(modelsTried.includes("gemini-pro-agent"), "second candidate attempted"); - assert.ok(modelsTried.includes("gemini-3-pro-high"), "third candidate reached"); - assert.ok( - modelsTried.lastIndexOf("gemini-pro-agent") < modelsTried.indexOf("gemini-3-pro-high"), - "second candidate exhausted before third tried" - ); } finally { globalThis.fetch = originalFetch; } @@ -323,7 +261,7 @@ test("(#3786) mixed path: 400 on first, exception on second, 200 on third", asyn test("(#3786) a non-pro model that 400s does NOT trigger the fallback chain", async () => { const executor = new AntigravityExecutor(); const originalFetch = globalThis.fetch; - seedAntigravityVersionCache("2026.04.17-test"); + seedAntigravityIdeVersionCache("2.1.1"); const modelsTried: string[] = []; globalThis.fetch = (async (_url: string, init?: RequestInit) => { @@ -351,20 +289,20 @@ test("(#3786) a non-pro model that 400s does NOT trigger the fallback chain", as test("(#3786) mixed: first 400 + last throws returns firstResult (original 400) instead of throwing", async () => { const executor = new AntigravityExecutor(); const originalFetch = globalThis.fetch; - seedAntigravityVersionCache("2026.04.17-test"); + seedAntigravityIdeVersionCache("2.1.1"); const modelsTried: string[] = []; globalThis.fetch = (async (_url: string, init?: RequestInit) => { const m = envelopeModel(init); modelsTried.push(m); - if (m === "gemini-3.1-pro-high") return make400(m); - // Second and third candidates throw + if (m === "gemini-3.1-pro-low") return make400(m); + // The final fallback candidate throws. throw new Error(`connection reset on ${m}`); }) as typeof fetch; try { const result = await executor.execute({ - model: "antigravity/gemini-3.1-pro-high", + model: "antigravity/gemini-3.1-pro-low", body: { request: { contents: [] } }, stream: false, credentials: { accessToken: "token", projectId: "project-1" }, @@ -377,10 +315,9 @@ test("(#3786) mixed: first 400 + last throws returns firstResult (original 400) assert.ok(payload.error, "must carry an error object"); assert.equal(typeof payload.error.message, "string"); assert.ok(!payload.error.message.includes("at /"), "no raw stack trace (hard rule #12)"); - // All candidates tried (executeOnce retries internally, so duplicates are expected). - assert.ok(modelsTried.includes("gemini-3.1-pro-high"), "first candidate tried"); - assert.ok(modelsTried.includes("gemini-pro-agent"), "second candidate tried"); - assert.ok(modelsTried.includes("gemini-3-pro-high"), "third candidate tried"); + // Both candidates are tried (executeOnce retries internally, so duplicates are expected). + assert.ok(modelsTried.includes("gemini-3.1-pro-low"), "first candidate tried"); + assert.ok(modelsTried.includes("gemini-3-pro-low"), "second candidate tried"); } finally { globalThis.fetch = originalFetch; } @@ -389,7 +326,7 @@ test("(#3786) mixed: first 400 + last throws returns firstResult (original 400) test("(#3786) AbortError from standard Error (not DOMException) propagates immediately", async () => { const executor = new AntigravityExecutor(); const originalFetch = globalThis.fetch; - seedAntigravityVersionCache("2026.04.17-test"); + seedAntigravityIdeVersionCache("2.1.1"); // Signal is NOT aborted -- this exercises the new Error.name === "AbortError" path. const controller = new AbortController(); @@ -404,7 +341,7 @@ test("(#3786) AbortError from standard Error (not DOMException) propagates immed await assert.rejects( () => executor.execute({ - model: "antigravity/gemini-3.1-pro-high", + model: "antigravity/gemini-3.1-pro-low", body: { request: { contents: [] } }, stream: false, credentials: { accessToken: "token", projectId: "project-1" }, diff --git a/tests/unit/agy-provider.test.ts b/tests/unit/agy-provider.test.ts index 13ed8097c1..c459c44193 100644 --- a/tests/unit/agy-provider.test.ts +++ b/tests/unit/agy-provider.test.ts @@ -46,16 +46,25 @@ test("agy reuses the identical antigravity Google OAuth credentials (no new embe assert.equal(OAUTH_PROVIDER_IDS.AGY, "agy"); }); -test("agy ships its own catalog including the Claude models antigravity omits", () => { +test("agy ships its own live callable model catalog", () => { const ids = REGISTRY.agy.models.map((m) => m.id); assert.ok(ids.includes("claude-opus-4-6-thinking"), "must expose Claude Opus 4.6 Thinking"); assert.ok(ids.includes("claude-sonnet-4-6"), "must expose Claude Sonnet 4.6"); - assert.ok(ids.includes("gemini-3.5-flash-low"), "must expose clean Flash Low tier"); - assert.ok(ids.includes("gemini-3.5-flash-medium"), "must expose clean Flash Medium tier"); - assert.ok(ids.includes("gemini-3.5-flash-high"), "must expose clean Flash High tier"); - assert.ok(!ids.includes("gemini-3-flash-agent")); + assert.ok(ids.includes("gemini-3.6-flash-low"), "must expose Gemini 3.6 Flash Low"); + assert.ok(ids.includes("gemini-3.6-flash-medium"), "must expose Gemini 3.6 Flash Medium"); + assert.ok(ids.includes("gemini-3.6-flash-high"), "must expose Gemini 3.6 Flash High"); + assert.ok(ids.includes("gemini-3.5-flash-extra-low"), "must expose upstream Flash Low tier"); + assert.ok(ids.includes("gemini-3.5-flash-low"), "must expose upstream Flash Medium tier"); + assert.ok(ids.includes("gemini-3-flash-agent"), "must expose upstream Flash High tier"); + assert.ok(!ids.includes("gemini-3.5-flash-medium")); + assert.ok(!ids.includes("gemini-3.5-flash-high")); + assert.ok(!ids.includes("gemini-3.5-flash-preview")); assert.ok(!ids.includes("gemini-3-flash")); - assert.ok(!ids.includes("gemini-3.5-flash-extra-low")); + assert.ok(!ids.includes("gemini-3.1-pro-high"), "must not expose rejected Pro High id"); + assert.ok(ids.includes("gemini-pro-agent"), "must expose callable Pro High id"); + assert.ok(!ids.includes("gemini-2.5-pro"), "must not expose unavailable Gemini 2.5 Pro"); + assert.ok(ids.includes("gemini-2.5-flash"), "must retain working Gemini 2.5 Flash"); + assert.ok(ids.includes("gemini-2.5-flash-lite"), "must retain working Gemini 2.5 Flash Lite"); // Tab-completion models are not chat-callable and must be excluded. assert.ok(!ids.includes("tab_flash_lite_preview")); assert.ok(!ids.includes("tab_jump_flash_lite_preview")); @@ -64,12 +73,38 @@ test("agy ships its own catalog including the Claude models antigravity omits", test("agy model helpers resolve catalog ids and display names", () => { assert.equal(isUserCallableAgyModelId("claude-opus-4-6-thinking"), true); + assert.equal(isUserCallableAgyModelId("gemini-2.5-pro"), false); + assert.equal(isUserCallableAgyModelId("gemini-2.5-flash"), true); + assert.equal(isUserCallableAgyModelId("gemini-3.1-pro-high"), false); + assert.equal(isUserCallableAgyModelId("gemini-pro-agent"), true); + assert.equal(isUserCallableAgyModelId("gemini-3.6-flash-low"), true); + assert.equal(isUserCallableAgyModelId("gemini-3.6-flash-medium"), true); + assert.equal(isUserCallableAgyModelId("gemini-3.6-flash-high"), true); + assert.equal(isUserCallableAgyModelId("gemini-3.5-flash-extra-low"), true); + assert.equal(isUserCallableAgyModelId("gemini-3.5-flash-low"), true); + assert.equal(isUserCallableAgyModelId("gemini-3-flash-agent"), true); + assert.equal(isUserCallableAgyModelId("gemini-3.5-flash-medium"), false); + assert.equal(isUserCallableAgyModelId("gemini-3.5-flash-high"), false); + assert.equal(isUserCallableAgyModelId("gemini-3.5-flash-preview"), false); assert.equal(isUserCallableAgyModelId("tab_flash_lite_preview"), false); assert.equal(isUserCallableAgyModelId(""), false); assert.equal( getClientVisibleAgyModelName("claude-opus-4-6-thinking"), "Claude Opus 4.6 (Thinking)" ); + assert.equal(getClientVisibleAgyModelName("gemini-pro-agent"), "Gemini 3.1 Pro (High)"); + assert.equal( + getClientVisibleAgyModelName("gemini-3.5-flash-extra-low"), + "Gemini 3.5 Flash (Low)" + ); + assert.equal(getClientVisibleAgyModelName("gemini-3.6-flash-low"), "Gemini 3.6 Flash (Low)"); + assert.equal( + getClientVisibleAgyModelName("gemini-3.6-flash-medium"), + "Gemini 3.6 Flash (Medium)" + ); + assert.equal(getClientVisibleAgyModelName("gemini-3.6-flash-high"), "Gemini 3.6 Flash (High)"); + assert.equal(getClientVisibleAgyModelName("gemini-3.5-flash-low"), "Gemini 3.5 Flash (Medium)"); + assert.equal(getClientVisibleAgyModelName("gemini-3-flash-agent"), "Gemini 3.5 Flash (High)"); assert.equal(getClientVisibleAgyModelName("unknown-model", "Fallback"), "Fallback"); }); diff --git a/tests/unit/agy-usage-quota.test.ts b/tests/unit/agy-usage-quota.test.ts index a99f8e1ac9..3ac4508107 100644 --- a/tests/unit/agy-usage-quota.test.ts +++ b/tests/unit/agy-usage-quota.test.ts @@ -48,13 +48,13 @@ test("getUsageForProvider routes agy through the Antigravity usage implementatio ); assert.ok("quotas" in result, "agy should return quota data when upstream responds"); - const quota = (result as { quotas: Record }).quotas["gemini-3.5-flash-high"]; - assert.ok(quota, "should expose the clean agy per-model quota"); + const quota = (result as { quotas: Record }).quotas["gemini-3-flash-agent"]; + assert.ok(quota, "should expose the upstream agy per-model quota"); assert.equal(quota.remainingPercentage, 75); assert.equal( - (result as { quotas: Record }).quotas["gemini-3-flash-agent"], + (result as { quotas: Record }).quotas["gemini-3.5-flash-high"], undefined, - "agy quota should not expose retired upstream IDs" + "agy quota should not expose retired friendly IDs" ); } finally { globalThis.fetch = originalFetch; @@ -65,7 +65,7 @@ test("parseQuotaData treats agy quota payloads like Antigravity", () => { const parsed = providerLimitUtils.parseQuotaData("agy", { quotas: { credits: { remaining: 42 }, - "gemini-3.5-flash-high": { + "gemini-3-flash-agent": { used: 250, total: 1000, remainingPercentage: 75, @@ -83,7 +83,7 @@ test("parseQuotaData treats agy quota payloads like Antigravity", () => { assert.ok(credits, "credits quota should be rendered"); assert.equal(credits.isCredits, true); - const modelQuota = parsed.find((quota: any) => quota.name === "gemini-3.5-flash-high"); + const modelQuota = parsed.find((quota: any) => quota.name === "gemini-3-flash-agent"); assert.ok(modelQuota, "model quota should be rendered"); assert.equal(modelQuota.remainingPercentage, 75); }); diff --git a/tests/unit/antigravity-client-identity-paths.test.ts b/tests/unit/antigravity-client-identity-paths.test.ts new file mode 100644 index 0000000000..8801750201 --- /dev/null +++ b/tests/unit/antigravity-client-identity-paths.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ag-identity-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-antigravity-client-identity"; + +const { AntigravityExecutor } = await import("../../open-sse/executors/antigravity.ts"); +const { setCliCompatProviders } = await import("../../open-sse/config/cliFingerprints.ts"); +const { antigravityCliUserAgent } = await import("../../open-sse/services/antigravityHeaders.ts"); +const { clearAntigravityVersionCaches, seedAntigravityCliVersionCache } = + await import("../../open-sse/services/antigravityVersion.ts"); +const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts"); +const core = await import("../../src/lib/db/core.ts"); + +const originalFetch = globalThis.fetch; +const originalCreditsMode = process.env.ANTIGRAVITY_CREDITS; + +test.afterEach(() => { + globalThis.fetch = originalFetch; + setCliCompatProviders([]); + clearAntigravityVersionCaches(); + if (originalCreditsMode === undefined) { + delete process.env.ANTIGRAVITY_CREDITS; + } else { + process.env.ANTIGRAVITY_CREDITS = originalCreditsMode; + } +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("executor token refresh uses the selected CLI identity", async () => { + seedAntigravityCliVersionCache("1.1.1"); + globalThis.fetch = async (url, init) => { + assert.match(String(url), /oauth2\.googleapis\.com\/token$/); + assert.equal(new Headers(init?.headers).get("User-Agent"), antigravityCliUserAgent("1.1.1")); + return Response.json({ + access_token: "new-token", + refresh_token: "new-refresh", + expires_in: 3600, + }); + }; + + const result = await new AntigravityExecutor().refreshCredentials( + { + refreshToken: "refresh", + projectId: "project-1", + providerSpecificData: { clientProfile: "cli" }, + }, + null + ); + + assert.equal(result?.accessToken, "new-token"); + assert.deepEqual(result?.providerSpecificData, { clientProfile: "cli" }); +}); + +test("credits retry keeps the selected CLI identity after fingerprint serialization", async () => { + const calls: Array<{ body: Record; headers: Headers }> = []; + seedAntigravityCliVersionCache("1.1.1"); + setCliCompatProviders(["antigravity"]); + process.env.ANTIGRAVITY_CREDITS = "retry"; + + globalThis.fetch = async (_url, init) => { + calls.push({ body: JSON.parse(String(init?.body)), headers: new Headers(init?.headers) }); + if (calls.length === 1) { + return Response.json( + { error: { message: "RESOURCE_EXHAUSTED: quota exhausted" } }, + { status: 429 } + ); + } + return new Response( + 'data: {"response":{"candidates":[{"content":{"parts":[{"text":"credits"}]},"finishReason":"STOP"}]}}\n\n', + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); + }; + + const result = await new AntigravityExecutor().execute({ + model: "antigravity/gemini-2.5-flash", + body: { request: { contents: [] } }, + stream: false, + credentials: { + accessToken: "cli-credit-token", + projectId: "project-1", + providerSpecificData: { clientProfile: "cli" }, + }, + log: { debug() {}, warn() {}, info() {} }, + }); + + assert.equal(result.response.status, 200); + assert.equal(calls.length, 2); + assert.deepEqual(calls[1].body.enabledCreditTypes, ["GOOGLE_ONE_AI"]); + for (const call of calls) { + assert.equal(call.headers.get("User-Agent"), antigravityCliUserAgent("1.1.1")); + assert.equal(call.headers.get("x-client-name"), null); + assert.equal(call.headers.get("X-Goog-Api-Client"), null); + } +}); + +test("image generation forwards the selected CLI identity and public envelope", async () => { + seedAntigravityCliVersionCache("1.1.1"); + let capturedHeaders = new Headers(); + let capturedBody: Record = {}; + globalThis.fetch = async (_url, init) => { + capturedHeaders = new Headers(init?.headers); + capturedBody = JSON.parse(String(init?.body)); + return Response.json({ + response: { + candidates: [ + { + content: { + parts: [{ inlineData: { mimeType: "image/png", data: "aW1hZ2U=" } }], + }, + }, + ], + }, + }); + }; + + const result = await handleImageGeneration({ + body: { + model: "antigravity/gemini-3.1-flash-image", + prompt: "painted beach", + size: "1024x1024", + }, + credentials: { + accessToken: "image-token", + projectId: "image-project", + providerSpecificData: { clientProfile: "cli" }, + }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(capturedHeaders.get("User-Agent"), antigravityCliUserAgent("1.1.1")); + assert.equal(capturedHeaders.get("x-client-name"), null); + assert.equal(capturedHeaders.get("X-Goog-Api-Client"), null); + assert.equal(capturedBody.userAgent, "antigravity"); + assert.equal(capturedBody.requestType, "image_gen"); +}); diff --git a/tests/unit/antigravity-client-profile.test.ts b/tests/unit/antigravity-client-profile.test.ts index 984e35c660..4644fd421e 100644 --- a/tests/unit/antigravity-client-profile.test.ts +++ b/tests/unit/antigravity-client-profile.test.ts @@ -1,143 +1,128 @@ -import { test } from "node:test"; import assert from "node:assert/strict"; -import { createRequire } from "node:module"; +import test from "node:test"; -import { normalizeAntigravityClientProfile } from "../../src/shared/constants/antigravityClientProfile.ts"; +import { + normalizeAntigravityClientProfile, + type AntigravityClientProfile, +} from "../../src/shared/constants/antigravityClientProfile.ts"; +import { validateProviderSpecificData } from "../../src/shared/validation/providerSpecificData.ts"; import { applyAntigravityClientProfileHeaders, - getAntigravityBootstrapHeaders, - antigravityHarnessUserAgent, getAntigravityClientProfile, } from "../../open-sse/services/antigravityClientProfile.ts"; -import { antigravityUserAgent } from "../../open-sse/services/antigravityHeaders.ts"; -import { deriveAntigravityMachineId } from "../../open-sse/services/antigravityIdentity.ts"; +import { getAntigravityEnvelopeUserAgent } from "../../open-sse/services/antigravityIdentity.ts"; import { - seedAntigravityVersionCache, - clearAntigravityVersionCache, + clearAntigravityVersionCaches, + seedAntigravityCliVersionCache, + seedAntigravityIdeVersionCache, } from "../../open-sse/services/antigravityVersion.ts"; -const require = createRequire(import.meta.url); -const { machineIdSync } = require("node-machine-id") as { - machineIdSync: (original?: boolean) => string; -}; - test.afterEach(() => { - clearAntigravityVersionCache(); + clearAntigravityVersionCaches(); }); -test("normalizeAntigravityClientProfile maps cli/sdk aliases to harness", () => { - assert.equal(normalizeAntigravityClientProfile("cli"), "harness"); - assert.equal(normalizeAntigravityClientProfile("SDK"), "harness"); +test("normalizeAntigravityClientProfile maps persisted legacy profiles to CLI", () => { + assert.equal(normalizeAntigravityClientProfile("cli"), "cli"); + assert.equal(normalizeAntigravityClientProfile("CLI"), "cli"); assert.equal(normalizeAntigravityClientProfile("ide"), "ide"); assert.equal(normalizeAntigravityClientProfile(undefined), "ide"); + assert.equal(normalizeAntigravityClientProfile(null), "ide"); + assert.equal(normalizeAntigravityClientProfile("harness"), "cli"); + assert.equal(normalizeAntigravityClientProfile("sdk"), "cli"); + assert.equal(normalizeAntigravityClientProfile(""), "ide"); + assert.equal(normalizeAntigravityClientProfile(42), "ide"); }); -test("getAntigravityClientProfile reads providerSpecificData.clientProfile per connection", () => { +function validateClientProfile(value: unknown): string[] { + const messages: string[] = []; + const ctx = { + addIssue: (issue: { message: string }) => messages.push(issue.message), + } as unknown as Parameters[1]; + + validateProviderSpecificData({ clientProfile: value }, ctx); + return messages; +} + +test("provider-specific validation rejects legacy Antigravity client profiles", () => { + assert.deepEqual(validateClientProfile("ide"), []); + assert.deepEqual(validateClientProfile("CLI"), []); + assert.deepEqual(validateClientProfile(undefined), []); + assert.deepEqual(validateClientProfile(null), []); + + for (const invalid of ["harness", "sdk", "", 42]) { + assert.deepEqual(validateClientProfile(invalid), [ + "providerSpecificData.clientProfile must be ide or cli", + ]); + } +}); + +test("getAntigravityClientProfile preserves legacy CLI identity for persisted values", () => { assert.equal( - getAntigravityClientProfile({ - providerSpecificData: { clientProfile: "harness" }, - }), - "harness" + getAntigravityClientProfile({ providerSpecificData: { clientProfile: "cli" } }), + "cli" ); assert.equal(getAntigravityClientProfile({ providerSpecificData: {} }), "ide"); + assert.equal( + getAntigravityClientProfile({ providerSpecificData: { clientProfile: "harness" } }), + "cli" + ); }); -test("applyAntigravityClientProfileHeaders uses IDE fingerprint by default", () => { - seedAntigravityVersionCache("4.2.0"); +function assertIdentityHeadersAbsent(headers: Record): void { + const normalized = new Headers(headers); + for (const name of [ + "x-client-name", + "x-client-version", + "x-machine-id", + "x-vscode-sessionid", + "X-Goog-Api-Client", + "Client-Metadata", + ]) { + assert.equal(normalized.get(name), null, `${name} must be removed`); + } +} + +function applyProfile(profile: AntigravityClientProfile): Record { const headers: Record = { Authorization: "Bearer token", "Content-Type": "application/json", + "X-Client-Name": "legacy-name", + "x-client-version": "4.2.0", + "X-Machine-Id": "legacy-machine", + "x-vscode-sessionid": "legacy-session", + "X-Goog-Api-Client": "legacy-api-client", + "client-metadata": "legacy-metadata", }; applyAntigravityClientProfileHeaders( headers, - { connectionId: "conn-1", providerSpecificData: {} }, + { connectionId: `connection-${profile}`, providerSpecificData: { clientProfile: profile } }, { project: "project-1" } ); + return headers; +} - assert.match(headers["User-Agent"], /^Antigravity\/4\.2\.0 /); - assert.equal(headers["x-client-name"], "antigravity"); - assert.equal(headers["x-client-version"], "4.2.0"); - assert.equal(typeof headers["x-machine-id"], "string"); - assert.equal(typeof headers["x-vscode-sessionid"], "string"); - assert.equal(headers["x-goog-user-project"], "project-1"); - assert.equal(headers["X-Goog-Api-Client"], undefined); +test("content header application emits IDE and CLI identities and strips fake headers", () => { + seedAntigravityIdeVersionCache("2.1.1"); + seedAntigravityCliVersionCache("1.1.1"); + + const ideHeaders = applyProfile("ide"); + const cliHeaders = applyProfile("cli"); + + assert.match(ideHeaders["User-Agent"], /^antigravity\/ide\/2\.1\.1 /); + assert.match(ideHeaders["User-Agent"], / darwin\/arm64$| windows\/amd64$| linux\/[^ ]+$/); + assert.match( + cliHeaders["User-Agent"], + /^antigravity\/cli\/1\.1\.1 \(aidev_client; os_type=.+; arch=.+; auth_method=consumer\)$/ + ); + assertIdentityHeadersAbsent(ideHeaders); + assertIdentityHeadersAbsent(cliHeaders); + assert.equal(ideHeaders["x-goog-user-project"], "project-1"); + assert.equal(cliHeaders["x-goog-user-project"], "project-1"); }); -test("antigravityUserAgent matches Antigravity Manager platform fingerprints", () => { - assert.equal( - antigravityUserAgent("4.2.0", "darwin"), - "Antigravity/4.2.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/142.0.7444.175 Electron/39.2.3" - ); - assert.equal( - antigravityUserAgent("4.2.0", "win32"), - "Antigravity/4.2.0 (Windows NT 10.0; Win64; x64) Chrome/142.0.7444.175 Electron/39.2.3" - ); - assert.equal( - antigravityUserAgent("4.2.0", "linux"), - "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/142.0.7444.175 Electron/39.2.3" - ); -}); - -test("deriveAntigravityMachineId uses the raw system machine id like Antigravity Manager", () => { - let systemMachineId: string | null = null; - try { - systemMachineId = machineIdSync(true); - } catch { - systemMachineId = null; - } - if (!systemMachineId) return; - - assert.equal(deriveAntigravityMachineId(), systemMachineId); -}); - -test("antigravityHarnessUserAgent uses Go harness platform and arch names", () => { - assert.equal( - antigravityHarnessUserAgent("2.1.1", "darwin", "arm64"), - "antigravity/2.1.1 darwin/arm64" - ); - assert.equal( - antigravityHarnessUserAgent("2.1.1", "win32", "x64"), - "antigravity/2.1.1 windows/amd64" - ); - assert.equal( - antigravityHarnessUserAgent("2.1.1", "linux", "x64"), - "antigravity/2.1.1 linux/amd64" - ); -}); - -test("getAntigravityBootstrapHeaders uses SDK loadCodeAssist harness fingerprint", () => { - seedAntigravityVersionCache("2.1.1"); - - const headers = getAntigravityBootstrapHeaders("harness", "token"); - - assert.equal(headers.Authorization, "Bearer token"); - assert.equal( - headers["User-Agent"], - `${antigravityHarnessUserAgent("2.1.1")} google-api-nodejs-client/10.3.0` - ); - assert.equal(headers["X-Goog-Api-Client"], "gl-node/22.21.1"); - assert.equal(headers["Client-Metadata"], undefined); -}); - -test("applyAntigravityClientProfileHeaders uses harness fingerprint when configured", () => { - seedAntigravityVersionCache("4.2.0"); - const headers: Record = { - Authorization: "Bearer token", - "Content-Type": "application/json", - "x-client-name": "antigravity", - "x-vscode-sessionid": "old-session", - }; - - applyAntigravityClientProfileHeaders( - headers, - { connectionId: "conn-2", providerSpecificData: { clientProfile: "harness" } }, - { project: "project-2" } - ); - - assert.equal(headers["User-Agent"], antigravityHarnessUserAgent("4.2.0")); - assert.equal(headers["X-Goog-Api-Client"], undefined); - assert.equal(headers["x-client-name"], undefined); - assert.equal(headers["x-vscode-sessionid"], undefined); - assert.equal(headers["x-goog-user-project"], "project-2"); +test("public request envelopes never infer the internal jetski identity from email", () => { + assert.equal(getAntigravityEnvelopeUserAgent({ email: "user@gmail.com" }), "antigravity"); + assert.equal(getAntigravityEnvelopeUserAgent({ email: "user@company.example" }), "antigravity"); + assert.equal(getAntigravityEnvelopeUserAgent(null), "antigravity"); }); diff --git a/tests/unit/antigravity-discovery-bootstrap.test.ts b/tests/unit/antigravity-discovery-bootstrap.test.ts index 11f8c43097..a02427796f 100644 --- a/tests/unit/antigravity-discovery-bootstrap.test.ts +++ b/tests/unit/antigravity-discovery-bootstrap.test.ts @@ -126,7 +126,7 @@ describe("ensureAntigravityProjectAssigned", () => { assert.equal(capturedAuth, "Bearer my-secret-token", "Authorization header must be set"); }); - test("uses CLI/SDK harness headers when requested", async () => { + test("uses the official CLI content headers when requested", async () => { let capturedHeaders: Headers | null = null; const mockFetch = async (_url: string, init?: RequestInit): Promise => { @@ -137,44 +137,48 @@ describe("ensureAntigravityProjectAssigned", () => { }); }; - await ensureAntigravityProjectAssigned("harness-token", mockFetch, "harness"); + await ensureAntigravityProjectAssigned("cli-token", mockFetch, "cli"); assert.match( capturedHeaders?.get("User-Agent") || "", - /^antigravity\/4\.2\.0 [^ ]+\/[^ ]+ google-api-nodejs-client\/10\.3\.0$/ + /^antigravity\/cli\/1\.1\.5 \(aidev_client; os_type=.+; arch=.+; auth_method=consumer\)$/ ); - assert.equal(capturedHeaders?.get("X-Goog-Api-Client"), "gl-node/22.21.1"); + assert.equal(capturedHeaders?.get("X-Goog-Api-Client"), null); assert.equal(capturedHeaders?.get("Client-Metadata"), null); }); - test("falls through to next URL when first loadCodeAssist returns 404", async () => { + test("uses the official IDE native content headers by default", async () => { + let capturedHeaders: Headers | null = null; + const mockFetch = async (_url: string, init?: RequestInit): Promise => { + capturedHeaders = new Headers(init?.headers); + return Response.json({ cloudaicompanionProject: "proj-ide" }); + }; + + await ensureAntigravityProjectAssigned("ide-token", mockFetch); + + assert.match(capturedHeaders?.get("User-Agent") || "", /^antigravity\/ide\/2\.1\.1 /); + assert.equal(capturedHeaders?.get("X-Goog-Api-Client"), null); + assert.equal(capturedHeaders?.get("Client-Metadata"), null); + }); + + test("bootstrap uses the single stable production loadCodeAssist endpoint and stays non-fatal on 404", async () => { const hitUrls: string[] = []; const mockFetch = async (url: string, _init?: RequestInit): Promise => { hitUrls.push(url); - // Exact hostname match (not substring .includes) so the check can't be fooled by a - // look-alike host like daily-cloudcode-pa.googleapis.com.evil.com (CodeQL - // js/incomplete-url-substring-sanitization). - if (new URL(url).hostname === "daily-cloudcode-pa.googleapis.com") { - // First URL fails - return new Response("not found", { status: 404 }); - } - // Second URL succeeds - return new Response(JSON.stringify({ cloudaicompanionProject: "proj-fallback" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + return new Response("not found", { status: 404 }); }; - const projectId = await ensureAntigravityProjectAssigned("fallback-token", mockFetch); + const projectId = await ensureAntigravityProjectAssigned("bootstrap-404-token", mockFetch); - assert.ok(hitUrls.length >= 2, "should try at least two URLs on the first failure"); - assert.equal(projectId, "proj-fallback", "should return the project from the successful URL"); - assert.equal( - getAntigravityProjectFromCache("fallback-token"), - "proj-fallback", - "should cache the project from the successful URL" - ); + // #8098 narrowed the bootstrap to the single stable production endpoint (no + // daily/sandbox fallback), so a 404 has no next URL to try — the call fails closed + // (undefined) and the caller proceeds with any DB-stored project id. + assert.equal(hitUrls.length, 1, "bootstrap tries exactly the one dedicated production URL"); + // Exact hostname match (not substring .includes) so the check can't be fooled by a + // look-alike host (CodeQL js/incomplete-url-substring-sanitization). + assert.equal(new URL(hitUrls[0]).hostname, "cloudcode-pa.googleapis.com"); + assert.equal(projectId, undefined, "a 404 bootstrap is non-fatal and returns undefined"); }); test("getAntigravityLoadCodeAssistUrls returns URLs matching ANTIGRAVITY_BASE_URLS", () => { diff --git a/tests/unit/antigravity-headers.test.ts b/tests/unit/antigravity-headers.test.ts index b65833d648..957ff3e7d8 100644 --- a/tests/unit/antigravity-headers.test.ts +++ b/tests/unit/antigravity-headers.test.ts @@ -1,8 +1,101 @@ -import test from "node:test"; import assert from "node:assert/strict"; -import { getAntigravityLoadCodeAssistMetadata } from "../../open-sse/services/antigravityHeaders.ts"; +import test from "node:test"; -test("loadCodeAssist metadata matches Antigravity Manager (ideType only)", () => { +import { + antigravityCliUserAgent, + antigravityIdeNodeUserAgent, + antigravityIdeUserAgent, + getAntigravityContentHeaders, + getAntigravityIdeNodeHeaders, + getAntigravityLoadCodeAssistMetadata, + getAntigravityOAuthUserAgent, +} from "../../open-sse/services/antigravityHeaders.ts"; +import { + clearAntigravityVersionCaches, + seedAntigravityCliVersionCache, + seedAntigravityIdeVersionCache, +} from "../../open-sse/services/antigravityVersion.ts"; + +test.afterEach(() => { + clearAntigravityVersionCaches(); +}); + +test("official IDE, IDE Node, and CLI User-Agent grammars match the native darwin/arm64 client", () => { + assert.equal(antigravityIdeUserAgent("2.1.1"), "antigravity/ide/2.1.1 darwin/arm64"); + assert.equal( + antigravityIdeNodeUserAgent("2.1.1"), + "antigravity/2.1.1 darwin/arm64 google-api-nodejs-client/10.3.0" + ); + assert.equal( + antigravityCliUserAgent("1.1.1"), + "antigravity/cli/1.1.1 (aidev_client; os_type=darwin; arch=arm64; auth_method=consumer)" + ); +}); + +test("User-Agent OS/arch token stays pinned to darwin/arm64 regardless of host (fingerprint fidelity)", () => { + // The upstream Antigravity backend expects the native macOS build, so OmniRoute presents + // that fingerprint no matter which platform it actually runs on (#8098 protocol fidelity). + // The CLI builder's second argument is authMethod, not platform — the OS/arch token is + // never host-derived, preserving the IDE/CLI User-Agent split (#8013). + assert.match(antigravityIdeUserAgent("2.1.1"), / darwin\/arm64$/); + assert.match(antigravityIdeNodeUserAgent("2.1.1"), / darwin\/arm64 /); + assert.equal( + antigravityCliUserAgent("1.1.1", "oauth"), + "antigravity/cli/1.1.1 (aidev_client; os_type=darwin; arch=arm64; auth_method=oauth)" + ); +}); + +test("IDE and CLI content headers use independent cached versions", () => { + seedAntigravityIdeVersionCache("2.2.0"); + seedAntigravityCliVersionCache("1.2.0"); + + const ideHeaders = new Headers(getAntigravityContentHeaders("ide", "ide-token")); + const cliHeaders = new Headers(getAntigravityContentHeaders("cli", "cli-token")); + + assert.match(ideHeaders.get("User-Agent") ?? "", /^antigravity\/ide\/2\.2\.0 /); + assert.match(cliHeaders.get("User-Agent") ?? "", /^antigravity\/cli\/1\.2\.0 /); + assert.equal(ideHeaders.get("Authorization"), "Bearer ide-token"); + assert.equal(cliHeaders.get("Authorization"), "Bearer cli-token"); + + for (const headers of [ideHeaders, cliHeaders]) { + for (const absent of [ + "x-client-name", + "x-client-version", + "x-machine-id", + "x-vscode-sessionid", + "X-Goog-Api-Client", + "Client-Metadata", + ]) { + assert.equal(headers.get(absent), null, `${absent} must be absent from content headers`); + } + } +}); + +test("IDE Node OAuth and onboarding headers use the captured Google Node identity", () => { + seedAntigravityIdeVersionCache("2.1.1"); + const headers = new Headers(getAntigravityIdeNodeHeaders("token")); + + assert.match( + headers.get("User-Agent") ?? "", + /^antigravity\/2\.1\.1 [^ ]+\/[^ ]+ google-api-nodejs-client\/10\.3\.0$/ + ); + assert.equal(headers.get("X-Goog-Api-Client"), "gl-node/22.21.1"); + assert.equal(headers.get("Authorization"), "Bearer token"); + assert.equal(headers.get("Client-Metadata"), null); +}); + +test("OAuth User-Agent selection keeps IDE and CLI identities independent", () => { + seedAntigravityIdeVersionCache("2.2.0"); + seedAntigravityCliVersionCache("1.2.0"); + + assert.match( + getAntigravityOAuthUserAgent("ide"), + /^antigravity\/2\.2\.0 [^ ]+\/[^ ]+ google-api-nodejs-client\/10\.3\.0$/ + ); + assert.match(getAntigravityOAuthUserAgent("cli"), /^antigravity\/cli\/1\.2\.0 /); +}); + +test("loadCodeAssist body metadata remains ideType only", () => { assert.deepEqual(getAntigravityLoadCodeAssistMetadata(), { ideType: "ANTIGRAVITY" }); assert.equal("platform" in getAntigravityLoadCodeAssistMetadata(), false); assert.equal("pluginType" in getAntigravityLoadCodeAssistMetadata(), false); diff --git a/tests/unit/antigravity-image-catalog-separation.test.ts b/tests/unit/antigravity-image-catalog-separation.test.ts new file mode 100644 index 0000000000..8ef8233eed --- /dev/null +++ b/tests/unit/antigravity-image-catalog-separation.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + ANTIGRAVITY_PUBLIC_MODELS, + isUserCallableAntigravityModelId, +} from "../../open-sse/config/antigravityModelAliases.ts"; +import { AGY_PUBLIC_MODELS, isUserCallableAgyModelId } from "../../open-sse/config/agyModels.ts"; +import { FREE_MODEL_BUDGETS } from "../../open-sse/config/freeModelCatalog.data.ts"; +import { getImageProvider } from "../../open-sse/config/imageRegistry.ts"; + +test("Antigravity and AGY chat catalogs exclude image-generation-only models", () => { + const chatModelIds = new Set(ANTIGRAVITY_PUBLIC_MODELS.map((model) => model.id)); + const agyChatModelIds = new Set(AGY_PUBLIC_MODELS.map((model) => model.id)); + + assert.equal(chatModelIds.has("gemini-3.1-flash-image"), false); + assert.equal(chatModelIds.has("gemini-3-pro-image-preview"), false); + assert.equal(isUserCallableAntigravityModelId("gemini-3.1-flash-image"), false); + assert.equal(isUserCallableAntigravityModelId("gemini-3-pro-image-preview"), false); + assert.equal(agyChatModelIds.has("gemini-3.1-flash-image"), false); + assert.equal(isUserCallableAgyModelId("gemini-3.1-flash-image"), false); + assert.equal( + FREE_MODEL_BUDGETS.some( + (model) => model.provider === "agy" && model.modelId === "gemini-3.1-flash-image" + ), + false + ); + + const imageProvider = getImageProvider("antigravity"); + assert.ok(imageProvider); + assert.equal( + imageProvider.models.some((model) => model.id === "gemini-3.1-flash-image"), + true + ); +}); diff --git a/tests/unit/antigravity-local-usage-fallback-3821.test.ts b/tests/unit/antigravity-local-usage-fallback-3821.test.ts index 16e5aa9724..d8b58b0078 100644 --- a/tests/unit/antigravity-local-usage-fallback-3821.test.ts +++ b/tests/unit/antigravity-local-usage-fallback-3821.test.ts @@ -3,11 +3,11 @@ * stale full `fetchAvailableModels` bucket (used=0) with real consumption summed from * `usage_history`, flipping quotaSource to "localUsageHistory". Every prior #3604 test * mocks only the HTTP layer, so the model-id match against usage_history.model was never - * exercised end-to-end. This seeds a real usage_history row keyed by the CLIENT tier id + * exercised end-to-end. This seeds a real usage_history row keyed by the upstream/public id * the fallback queries and asserts the flip — the regression guard for the id contract. * - * Contract note: the fallback queries `usage_history WHERE model = ` - * (e.g. gemini-3.5-flash-high), so the executor MUST log usage under that same client id + * Contract note: the fallback queries `usage_history WHERE model = ` + * (e.g. gemini-3-flash-agent), so the executor MUST log usage under that same model id * for the fallback to fire. This test pins exactly that join. */ import test from "node:test"; @@ -40,12 +40,14 @@ test("Antigravity fetchAvailableModels(used=0) → localUsageHistory when usage_ const resetTime = new Date(Date.now() + 60 * 60 * 1000).toISOString(); const seededTimestamp = new Date(Date.now() - 30 * 60 * 1000).toISOString(); // within window - // Seed a usage_history row keyed by the CLIENT tier id the fallback queries. - const db = core.getDbInstance() as unknown as { prepare: (sql: string) => { run: (...a: unknown[]) => unknown } }; + // Seed a usage_history row keyed by the public upstream id the fallback queries. + const db = core.getDbInstance() as unknown as { + prepare: (sql: string) => { run: (...a: unknown[]) => unknown }; + }; db.prepare( `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, tokens_reasoning, success, timestamp) VALUES (?, ?, ?, ?, ?, ?, 1, ?)` - ).run("antigravity", "gemini-3.5-flash-high", "conn-local-1", 1000, 1500, 500, seededTimestamp); + ).run("antigravity", "gemini-3-flash-agent", "conn-local-1", 1000, 1500, 500, seededTimestamp); // Total seeded tokens = 3000 → ceil(3000/1000) = 3 units used. globalThis.fetch = (async (input: any) => { @@ -59,7 +61,7 @@ test("Antigravity fetchAvailableModels(used=0) → localUsageHistory when usage_ ok: true, json: async () => ({ models: { - "gemini-3.5-flash-high": { + "gemini-3-flash-agent": { quotaInfo: { remainingFraction: 1.0, resetTime }, }, }, @@ -77,8 +79,8 @@ test("Antigravity fetchAvailableModels(used=0) → localUsageHistory when usage_ const result = await getUsageForProvider(connection, { forceRefresh: true }); assert.ok(result && "quotas" in result, "should return quotas"); - const quota = (result as any).quotas["gemini-3.5-flash-high"]; - assert.ok(quota, "should have the gemini-3.5-flash-high quota"); + const quota = (result as any).quotas["gemini-3-flash-agent"]; + assert.ok(quota, "should have the gemini-3-flash-agent quota"); assert.equal(quota.quotaSource, "localUsageHistory", "stale full bucket replaced by local usage"); assert.equal(quota.used, 3, "3000 seeded tokens → 3 units used"); }); @@ -97,7 +99,7 @@ test("Antigravity stays fetchAvailableModels when usage_history has no matching ok: true, json: async () => ({ models: { - "gemini-3.5-flash-high": { quotaInfo: { remainingFraction: 1.0, resetTime } }, + "gemini-3-flash-agent": { quotaInfo: { remainingFraction: 1.0, resetTime } }, }, }), } as Response; @@ -112,7 +114,7 @@ test("Antigravity stays fetchAvailableModels when usage_history has no matching }; const result = await getUsageForProvider(connection, { forceRefresh: true }); - const quota = (result as any).quotas["gemini-3.5-flash-high"]; + const quota = (result as any).quotas["gemini-3-flash-agent"]; assert.ok(quota, "should have the quota"); assert.equal(quota.quotaSource, "fetchAvailableModels", "no local rows → keep the catalog view"); assert.equal(quota.used, 0, "full bucket stays at 0 used"); diff --git a/tests/unit/antigravity-missing-project-chat.test.ts b/tests/unit/antigravity-missing-project-chat.test.ts new file mode 100644 index 0000000000..896fd44eba --- /dev/null +++ b/tests/unit/antigravity-missing-project-chat.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; + +const harness = await createChatPipelineHarness("antigravity-missing-project-chat"); +const { BaseExecutor, buildRequest, handleChat, resetStorage, settingsDb } = harness; +const providersDb = await import("../../src/lib/db/providers.ts"); +const { clearAntigravityProjectCache } = await import( + "../../open-sse/services/antigravityProjectBootstrap.ts" +); +const { seedAntigravityIdeVersionCache, seedAntigravityCliVersionCache } = await import( + "../../open-sse/services/antigravityVersion.ts" +); + +const BOOTSTRAP_URL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"; + +test.beforeEach(async () => { + BaseExecutor.RETRY_CONFIG.delayMs = 0; + process.env.ANTIGRAVITY_CREDITS = "off"; + await resetStorage(); + await settingsDb.updateSettings({ requestRetry: 0, maxRetryIntervalSec: 0 }); + clearAntigravityProjectCache(); + seedAntigravityIdeVersionCache("2026.04.17-missing-project-test"); + seedAntigravityCliVersionCache("2026.04.17-missing-project-test"); +}); + +test.afterEach(() => { + clearAntigravityProjectCache(); + delete process.env.ANTIGRAVITY_CREDITS; +}); + +test.after(async () => { + await harness.cleanup(); +}); + +test("Antigravity missing-project 422 stays fail-closed without account cooldown or retry", async () => { + const connection = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + name: "antigravity-missing-project", + email: "antigravity-missing-project@example.test", + accessToken: "fake-antigravity-missing-project-token", + refreshToken: "fake-antigravity-missing-project-refresh", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + providerSpecificData: {}, + isActive: true, + testStatus: "active", + }); + assert(connection && typeof connection.id === "string"); + + let bootstrapCalls = 0; + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + if (request.url !== BOOTSTRAP_URL) { + throw new Error(`Unexpected external fetch: ${request.url}`); + } + bootstrapCalls += 1; + return new Response("{}", { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const response = await handleChat( + buildRequest({ + body: { + model: "antigravity/gemini-2.5-flash", + stream: false, + messages: [{ role: "user", content: "Missing project must fail closed" }], + }, + }) + ); + const payload = (await response.json()) as { + error?: { code?: string; type?: string; message?: string }; + }; + const persisted = await providersDb.getProviderConnectionById(connection.id); + + assert.equal(response.status, 422); + assert.equal(payload.error?.code, "missing_project_id"); + assert.equal(payload.error?.type, "oauth_missing_project_id"); + assert.equal(bootstrapCalls, 1); + assert.equal(persisted?.testStatus, "active"); + assert.equal(persisted?.rateLimitedUntil, undefined); + assert.equal(persisted?.lastError, undefined); +}); diff --git a/tests/unit/antigravity-model-aliases.test.ts b/tests/unit/antigravity-model-aliases.test.ts index 9c80666ff5..8f587e6e25 100644 --- a/tests/unit/antigravity-model-aliases.test.ts +++ b/tests/unit/antigravity-model-aliases.test.ts @@ -16,19 +16,25 @@ function getPublicModel(id: string) { return ANTIGRAVITY_PUBLIC_MODELS.find((model) => model.id === id) as any; } -// #3821-review LEDGER-5 — the upstream quota-bucket → client-tier remap is now the single -// source of truth here (was duplicated as an inline if-ladder in usage.ts). It operates on -// the UPSTREAM quota namespace, where `gemini-3.5-flash-low` is the Medium tier's bucket. -test("toClientAntigravityQuotaModelId maps upstream quota buckets to client tiers", () => { - assert.equal( - toClientAntigravityQuotaModelId("gemini-3.5-flash-extra-low"), - "gemini-3.5-flash-low" - ); - // Dual-meaning id: in the quota namespace this bucket is the Medium tier. - assert.equal(toClientAntigravityQuotaModelId("gemini-3.5-flash-low"), "gemini-3.5-flash-medium"); - assert.equal(toClientAntigravityQuotaModelId("gemini-3-flash-agent"), "gemini-3.5-flash-high"); - // Non-tier ids fall back to the standard reverse alias map. - assert.equal(toClientAntigravityQuotaModelId("gemini-3.1-pro"), "gemini-3-pro-preview"); +const EXPECTED_FLASH_TIERS = [ + ["gemini-3.6-flash-low", "Gemini 3.6 Flash (Low)"], + ["gemini-3.6-flash-medium", "Gemini 3.6 Flash (Medium)"], + ["gemini-3.6-flash-high", "Gemini 3.6 Flash (High)"], + ["gemini-3.5-flash-extra-low", "Gemini 3.5 Flash (Low)"], + ["gemini-3.5-flash-low", "Gemini 3.5 Flash (Medium)"], + ["gemini-3-flash-agent", "Gemini 3.5 Flash (High)"], +] as const; + +const RETIRED_FLASH_IDS = [ + "gemini-3.5-flash-medium", + "gemini-3.5-flash-high", + "gemini-3.5-flash-preview", +] as const; + +test("toClientAntigravityQuotaModelId preserves upstream Gemini Flash bucket IDs", () => { + for (const [modelId] of EXPECTED_FLASH_TIERS) { + assert.equal(toClientAntigravityQuotaModelId(modelId), modelId); + } // Always-allowed bucket passes through unchanged. assert.equal(toClientAntigravityQuotaModelId("credits"), "credits"); // Retired preview buckets are dropped (hidden from clients). @@ -38,17 +44,10 @@ test("toClientAntigravityQuotaModelId maps upstream quota buckets to client tier }); test("resolveAntigravityModelId maps the documented Antigravity aliases to upstream IDs", () => { - assert.equal(resolveAntigravityModelId("gemini-3-pro-preview"), "gemini-3.1-pro"); assert.equal(resolveAntigravityModelId("gemini-3-pro-image-preview"), "gemini-3-pro-image"); - assert.equal( - resolveAntigravityModelId("gemini-2.5-computer-use-preview-10-2025"), - "rev19-uic3-1p" - ); - assert.equal(resolveAntigravityModelId("gemini-3.5-flash-low"), "gemini-3.5-flash-extra-low"); - assert.equal(resolveAntigravityModelId("gemini-3.5-flash-medium"), "gemini-3.5-flash-low"); - assert.equal(resolveAntigravityModelId("gemini-3.5-flash-high"), "gemini-3-flash-agent"); - // Backward-compat: retired flagship public id routes to the High tier upstream. - assert.equal(resolveAntigravityModelId("gemini-3.5-flash-preview"), "gemini-3-flash-agent"); + for (const [modelId] of EXPECTED_FLASH_TIERS) { + assert.equal(resolveAntigravityModelId(modelId), modelId); + } assert.equal(resolveAntigravityModelId("gemini-claude-sonnet-4-5"), "claude-sonnet-4-6"); assert.equal(resolveAntigravityModelId("gemini-claude-sonnet-4-5-thinking"), "claude-sonnet-4-6"); assert.equal( @@ -58,24 +57,27 @@ test("resolveAntigravityModelId maps the documented Antigravity aliases to upstr assert.equal(resolveAntigravityModelId("unknown-model"), "unknown-model"); }); -test("toClientAntigravityModelId exposes client-visible aliases for known upstream IDs", () => { - assert.equal(toClientAntigravityModelId("gemini-3.1-pro"), "gemini-3-pro-preview"); - assert.equal(toClientAntigravityModelId("gemini-3.5-flash-extra-low"), "gemini-3.5-flash-low"); - assert.equal(toClientAntigravityModelId("gemini-3-flash-agent"), "gemini-3.5-flash-high"); +test("toClientAntigravityModelId preserves public upstream IDs", () => { + for (const [modelId] of EXPECTED_FLASH_TIERS) { + assert.equal(toClientAntigravityModelId(modelId), modelId); + } assert.equal(toClientAntigravityModelId("gpt-oss-120b-medium"), "gpt-oss-120b-medium"); assert.equal(toClientAntigravityModelId("claude-sonnet-4-6"), "claude-sonnet-4-6"); assert.equal(toClientAntigravityModelId("claude-opus-4-6-thinking"), "claude-opus-4-6-thinking"); }); test("isUserCallableAntigravityModelId only allows public chat-capable model IDs", () => { - assert.equal(isUserCallableAntigravityModelId("gemini-3-pro-preview"), true); - assert.equal(isUserCallableAntigravityModelId("gemini-3.1-pro"), true); - // Retired flagship id stays callable as a hidden backward-compat alias (routes to High), - // even though it is no longer exposed in the public catalog. - assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-preview"), true); - assert.equal(isUserCallableAntigravityModelId("gemini-3-flash-agent"), true); + // Retired ids and their former upstream targets are neither aliased nor callable. + assert.equal(isUserCallableAntigravityModelId("gemini-3-pro-preview"), false); + assert.equal(isUserCallableAntigravityModelId("gemini-3.1-pro"), false); + for (const retiredId of RETIRED_FLASH_IDS) { + assert.equal(isUserCallableAntigravityModelId(retiredId), false); + } + for (const [modelId] of EXPECTED_FLASH_TIERS) { + assert.equal(isUserCallableAntigravityModelId(modelId), true); + } assert.equal(isUserCallableAntigravityModelId("gemini-3.1-flash-lite"), true); - assert.equal(isUserCallableAntigravityModelId("gemini-2.5-pro"), true); + assert.equal(isUserCallableAntigravityModelId("gemini-2.5-pro"), false); assert.equal(isUserCallableAntigravityModelId("gemini-2.5-flash"), true); assert.equal(isUserCallableAntigravityModelId("gemini-2.5-flash-lite"), true); assert.equal(isUserCallableAntigravityModelId("gemini-2.5-flash-thinking"), true); @@ -85,19 +87,16 @@ test("isUserCallableAntigravityModelId only allows public chat-capable model IDs // 2.0 was wrong. assert.equal(isUserCallableAntigravityModelId("claude-opus-4-6-thinking"), true); assert.equal(isUserCallableAntigravityModelId("claude-sonnet-4-6"), true); - assert.equal(isUserCallableAntigravityModelId("claude-sonnet-5"), true); - // Antigravity 2.0.4 exposes Gemini 3.5 Flash as separate UI tiers. - assert.equal(isUserCallableAntigravityModelId("gemini-3.1-pro-high"), true); + assert.equal(isUserCallableAntigravityModelId("claude-sonnet-5"), false); + // The advertised pro-high discovery slot rejects content requests; use pro-agent High. + assert.equal(isUserCallableAntigravityModelId("gemini-3.1-pro-high"), false); + assert.equal(isUserCallableAntigravityModelId("gemini-pro-agent"), true); assert.equal(isUserCallableAntigravityModelId("gemini-3.1-pro-low"), true); - assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-low"), true); - assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-medium"), true); - assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-high"), true); - assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-extra-low"), true); assert.equal(isUserCallableAntigravityModelId("tab_flash_lite_preview"), false); assert.equal(isUserCallableAntigravityModelId("unknown-model"), false); }); -test("ANTIGRAVITY_PUBLIC_MODELS exposes captured Antigravity 2.0.1 names and capabilities", () => { +test("ANTIGRAVITY_PUBLIC_MODELS exposes current live names and capabilities", () => { // #3184: Claude is exposed in the antigravity catalog (same backend as `agy`, verified). // #7129: Opus 4.6, Sonnet 4.6, and Sonnet 5 graduated to a 1M-token context window at GA // (Anthropic docs, platform.claude.com/docs/en/build-with-claude/context-windows: "Claude @@ -115,29 +114,22 @@ test("ANTIGRAVITY_PUBLIC_MODELS exposes captured Antigravity 2.0.1 names and cap }); assert.equal(getPublicModel("claude-sonnet-4-6").name, "Claude Sonnet 4.6 (Thinking)"); assert.equal(getPublicModel("claude-sonnet-4-6").contextLength, 1048576); - // claude-sonnet-5 was added to the Antigravity catalog alongside the existing Claude entries. - assert.deepEqual(getPublicModel("claude-sonnet-5"), { - id: "claude-sonnet-5", - name: "Claude Sonnet 5 (Thinking)", - contextLength: 1048576, - maxOutputTokens: 65536, - supportsReasoning: true, - supportsVision: true, - toolCalling: true, - }); - assert.deepEqual(getPublicModel("gemini-3.5-flash-high"), { - id: "gemini-3.5-flash-high", - name: "Gemini 3.5 Flash (High)", - contextLength: 1048576, - maxOutputTokens: 65536, - supportsReasoning: true, - supportsVision: true, - toolCalling: true, - }); - assert.equal( - getClientVisibleAntigravityModelName("gemini-3.5-flash-medium"), - "Gemini 3.5 Flash (Medium)" - ); + assert.equal(getPublicModel("claude-sonnet-5"), undefined); + for (const [modelId, displayName] of EXPECTED_FLASH_TIERS) { + assert.deepEqual(getPublicModel(modelId), { + id: modelId, + name: displayName, + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }); + assert.equal(getClientVisibleAntigravityModelName(modelId), displayName); + } + for (const retiredId of RETIRED_FLASH_IDS) { + assert.equal(getPublicModel(retiredId), undefined); + } assert.equal(getClientVisibleAntigravityModelName("gemini-2.5-flash"), "Gemini 2.5 Flash"); assert.equal( getClientVisibleAntigravityModelName("gemini-2.5-flash-lite"), @@ -155,11 +147,9 @@ test("ANTIGRAVITY_PUBLIC_MODELS exposes captured Antigravity 2.0.1 names and cap supportsReasoning: true, toolCalling: true, }); - assert.equal(getPublicModel("gemini-3-pro-image-preview").contextLength, undefined); - assert.equal( - getPublicModel("gemini-2.5-computer-use-preview-10-2025").maxOutputTokens, - undefined - ); + assert.equal(getPublicModel("gemini-3-pro-image-preview"), undefined); + assert.equal(getPublicModel("gemini-3.1-flash-image"), undefined); + assert.equal(getPublicModel("gemini-2.5-computer-use-preview-10-2025"), undefined); }); test("ANTIGRAVITY_PUBLIC_MODELS has no duplicate model IDs", () => { @@ -173,43 +163,24 @@ test("ANTIGRAVITY_PUBLIC_MODELS has no duplicate model IDs", () => { assert.deepEqual(duplicates, [], `duplicate model IDs found: ${duplicates.join(", ")}`); }); -test("AntigravityExecutor.transformRequest resolves alias models before dispatching upstream", async () => { +test("AntigravityExecutor.transformRequest preserves Gemini Flash upstream IDs", async () => { const executor = new AntigravityExecutor(); - const result = await executor.transformRequest( - "antigravity/gemini-3-pro-preview", - { - request: { - contents: [{ role: "user", parts: [{ text: "Hello" }] }], + for (const [modelId] of EXPECTED_FLASH_TIERS) { + const result = await executor.transformRequest( + `antigravity/${modelId}`, + { + request: { + contents: [{ role: "user", parts: [{ text: "Hello" }] }], + }, }, - }, - true, - { projectId: "project-1" } - ); + true, + { projectId: "project-1" } + ); - if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); - assert.equal(result.model, "gemini-3.1-pro"); -}); - -test("AntigravityExecutor.transformRequest maps Gemini 3.5 Flash tiers to live upstream IDs", async () => { - const executor = new AntigravityExecutor(); - const result = await executor.transformRequest( - "antigravity/gemini-3.5-flash-high", - { - request: { - contents: [{ role: "user", parts: [{ text: "Hello" }] }], - }, - }, - true, - { projectId: "project-1" } - ); - - if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); - // The "High" tier resolves to the live upstream id; the request body is forwarded - // under that id. (Dropped four assertions on modelConfigId/model_config_id — the - // executor never sets those fields, so they were vacuously true and gave false - // confidence. #3821-review LEDGER-10.) - assert.equal(result.model, "gemini-3-flash-agent"); - assert.deepEqual(result.request.contents, [{ role: "user", parts: [{ text: "Hello" }] }]); + if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); + assert.equal(result.model, modelId); + assert.deepEqual(result.request.contents, [{ role: "user", parts: [{ text: "Hello" }] }]); + } }); test("AntigravityExecutor.transformRequest sends Claude through Gemini-compatible Cloud Code schema", async () => { diff --git a/tests/unit/antigravity-oauth-client-profile.test.ts b/tests/unit/antigravity-oauth-client-profile.test.ts new file mode 100644 index 0000000000..2e7349c8e0 --- /dev/null +++ b/tests/unit/antigravity-oauth-client-profile.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { AGY_CONFIG, ANTIGRAVITY_CONFIG } from "../../src/lib/oauth/constants/oauth.ts"; +import { agy } from "../../src/lib/oauth/providers/agy.ts"; +import { antigravity } from "../../src/lib/oauth/providers/antigravity.ts"; +import { + clearAntigravityVersionCaches, + seedAntigravityCliVersionCache, + seedAntigravityIdeVersionCache, +} from "../../open-sse/services/antigravityVersion.ts"; + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; + clearAntigravityVersionCaches(); +}); + +test("token exchange selects the IDE Node and CLI User-Agent independently", async () => { + seedAntigravityIdeVersionCache("2.1.1"); + seedAntigravityCliVersionCache("1.1.1"); + const userAgents: string[] = []; + + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + userAgents.push(new Headers(init?.headers).get("User-Agent") ?? ""); + return Response.json({ access_token: "token", refresh_token: "refresh", expires_in: 3600 }); + }) as typeof fetch; + + await antigravity.exchangeToken(ANTIGRAVITY_CONFIG, "ide-code", "http://localhost/callback"); + await agy.exchangeToken(AGY_CONFIG, "cli-code", "http://localhost/callback"); + + assert.match( + userAgents[0], + /^antigravity\/2\.1\.1 [^ ]+\/[^ ]+ google-api-nodejs-client\/10\.3\.0$/ + ); + assert.match( + userAgents[1], + /^antigravity\/cli\/1\.1\.1 \(aidev_client; os_type=.+; arch=.+; auth_method=consumer\)$/ + ); +}); + +for (const [name, provider, expectedProfile] of [ + ["antigravity", antigravity, "ide"], + ["agy", agy, "cli"], +] as const) { + test(`${name} post-exchange and onboarding use the selected ${expectedProfile} identity`, async () => { + seedAntigravityIdeVersionCache("2.1.1"); + seedAntigravityCliVersionCache("1.1.1"); + const cloudCodeHeaders: Headers[] = []; + + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const urlString = String(url); + if (urlString.includes("userinfo")) { + return Response.json({ email: `${name}@example.com` }); + } + if (urlString.includes("loadCodeAssist")) { + cloudCodeHeaders.push(new Headers(init?.headers)); + return Response.json({ + cloudaicompanionProject: `${name}-project`, + allowedTiers: [{ id: "legacy-tier", isDefault: true }], + }); + } + if (urlString.includes("onboardUser")) { + cloudCodeHeaders.push(new Headers(init?.headers)); + return Response.json({ done: true }); + } + return Response.json({}); + }) as typeof fetch; + + const extra = await provider.postExchange({ access_token: `${name}-token` } as never); + const mapped = provider.mapTokens({ access_token: `${name}-token` } as never, extra); + await new Promise((resolve) => setTimeout(resolve, 25)); + + assert.ok(cloudCodeHeaders.length >= 2); + for (const headers of cloudCodeHeaders) { + if (expectedProfile === "ide") { + assert.match( + headers.get("User-Agent") ?? "", + /^antigravity\/2\.1\.1 [^ ]+\/[^ ]+ google-api-nodejs-client\/10\.3\.0$/ + ); + assert.equal(headers.get("X-Goog-Api-Client"), "gl-node/22.21.1"); + } else { + assert.match(headers.get("User-Agent") ?? "", /^antigravity\/cli\/1\.1\.1 /); + assert.equal(headers.get("X-Goog-Api-Client"), null); + } + assert.equal(headers.get("Client-Metadata"), null); + } + assert.equal(mapped.providerSpecificData.clientProfile, expectedProfile); + }); +} diff --git a/tests/unit/antigravity-retired-public-models.test.ts b/tests/unit/antigravity-retired-public-models.test.ts new file mode 100644 index 0000000000..0c3796c4fc --- /dev/null +++ b/tests/unit/antigravity-retired-public-models.test.ts @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getAntigravityModelFallbacks, + ANTIGRAVITY_MODEL_ALIASES, + ANTIGRAVITY_PUBLIC_MODELS, + ANTIGRAVITY_REVERSE_MODEL_ALIASES, + isUserCallableAntigravityModelId, + resolveAntigravityModelId, + toClientAntigravityModelId, +} from "../../open-sse/config/antigravityModelAliases.ts"; +import { AGY_PUBLIC_MODELS, isUserCallableAgyModelId } from "../../open-sse/config/agyModels.ts"; +import { FREE_MODEL_BUDGETS } from "../../open-sse/config/freeModelCatalog.data.ts"; +import { getDefaultPricing } from "../../src/shared/constants/pricing.ts"; +import { CLI_TOOLS } from "../../src/shared/constants/cliTools.ts"; + +const RETIRED_PUBLIC_MODELS = [ + "gemini-3-pro-preview", + "gemini-3.1-pro-high", + "gemini-2.5-pro", + "gemini-2.5-computer-use-preview-10-2025", +] as const; + +const EXPECTED_LEADING_MODEL_ORDER = [ + "gemini-3.6-flash-high", + "gemini-3.6-flash-medium", + "gemini-3.6-flash-low", + "claude-opus-4-6-thinking", + "claude-sonnet-4-6", + "gemini-pro-agent", + "gemini-3.1-pro-low", + "gemini-3-flash-agent", + "gemini-3.5-flash-low", + "gemini-3.5-flash-extra-low", +] as const; + +const ACTIVE_FLASH_MODEL_IDS = [ + "gemini-3-flash-agent", + "gemini-3.5-flash-low", + "gemini-3.5-flash-extra-low", +] as const; + +const CURRENT_36_FLASH_MODEL_IDS = [ + "gemini-3.6-flash-high", + "gemini-3.6-flash-medium", + "gemini-3.6-flash-low", +] as const; + +test("Antigravity and AGY place the live Gemini 3.6 default tiers first", () => { + for (const [provider, models] of [ + ["antigravity", ANTIGRAVITY_PUBLIC_MODELS], + ["agy", AGY_PUBLIC_MODELS], + ] as const) { + assert.deepEqual( + models.slice(0, EXPECTED_LEADING_MODEL_ORDER.length).map((model) => model.id), + EXPECTED_LEADING_MODEL_ORDER, + `${provider} public catalog must place the live Gemini 3.6 default tiers first` + ); + } +}); + +test("Antigravity excludes confirmed retired models from its public chat catalog", () => { + const publicModelIds = new Set(ANTIGRAVITY_PUBLIC_MODELS.map((model) => model.id)); + + for (const modelId of RETIRED_PUBLIC_MODELS) { + assert.equal(publicModelIds.has(modelId), false, `${modelId} must not be public`); + assert.equal( + isUserCallableAntigravityModelId(modelId), + false, + `${modelId} must not be discovered as callable` + ); + } +}); + +test("AGY free-model metadata excludes unavailable Gemini 2.5 Pro", () => { + assert.equal( + FREE_MODEL_BUDGETS.some( + (model) => model.provider === "agy" && model.modelId === "gemini-2.5-pro" + ), + false + ); +}); + +test("Antigravity and AGY expose gemini-pro-agent as the only Gemini 3.1 Pro High id", () => { + const antigravityModels = new Map( + ANTIGRAVITY_PUBLIC_MODELS.map((model) => [model.id, model.name]) + ); + const agyModels = new Map(AGY_PUBLIC_MODELS.map((model) => [model.id, model.name])); + const agyFreeModels = FREE_MODEL_BUDGETS.filter((model) => model.provider === "agy"); + + assert.equal(antigravityModels.has("gemini-3.1-pro-high"), false); + assert.equal(agyModels.has("gemini-3.1-pro-high"), false); + assert.equal(isUserCallableAntigravityModelId("gemini-3.1-pro-high"), false); + assert.equal(isUserCallableAgyModelId("gemini-3.1-pro-high"), false); + assert.deepEqual(getAntigravityModelFallbacks("gemini-3.1-pro-high"), []); + assert.equal( + agyFreeModels.some((model) => model.modelId === "gemini-3.1-pro-high"), + false + ); + + assert.equal(antigravityModels.get("gemini-pro-agent"), "Gemini 3.1 Pro (High)"); + assert.equal(agyModels.get("gemini-pro-agent"), "Gemini 3.1 Pro (High)"); + assert.equal(isUserCallableAntigravityModelId("gemini-pro-agent"), true); + assert.equal(isUserCallableAgyModelId("gemini-pro-agent"), true); + assert.equal( + agyFreeModels.find((model) => model.modelId === "gemini-pro-agent")?.displayName, + "Gemini 3.1 Pro (High)" + ); +}); + +test("Antigravity support catalogs expose every live Gemini 3.6 Flash tier", () => { + const antigravityModelIds = new Set(ANTIGRAVITY_PUBLIC_MODELS.map((model) => model.id)); + const agyModelIds = new Set(AGY_PUBLIC_MODELS.map((model) => model.id)); + const cliAliases = new Set(CLI_TOOLS.antigravity.modelAliases); + const cliModelIds = new Set(CLI_TOOLS.antigravity.defaultModels.map((model) => model.id)); + const agyFreeModelIds = new Set( + FREE_MODEL_BUDGETS.filter((model) => model.provider === "agy").map((model) => model.modelId) + ); + + for (const modelId of CURRENT_36_FLASH_MODEL_IDS) { + assert.equal(antigravityModelIds.has(modelId), true, `${modelId} missing from Antigravity`); + assert.equal(agyModelIds.has(modelId), true, `${modelId} missing from AGY`); + assert.equal(cliAliases.has(modelId), true, `${modelId} missing from CLI aliases`); + assert.equal(cliModelIds.has(modelId), true, `${modelId} missing from CLI defaults`); + assert.equal(agyFreeModelIds.has(modelId), true, `${modelId} missing from AGY metadata`); + } +}); + +test("Antigravity support catalogs no longer advertise or price the rejected High id", () => { + const cliModelIds = CLI_TOOLS.antigravity.defaultModels.map((model) => model.id); + const pricing = getDefaultPricing().ag; + + assert.equal(cliModelIds.includes("gemini-3.1-pro-high"), false); + assert.equal(cliModelIds.includes("gemini-pro-agent"), true); + assert.equal(pricing["gemini-3.1-pro-high"], undefined); + assert.ok(pricing["gemini-pro-agent"]); +}); + +test("Antigravity and AGY support metadata excludes the retired Gemini 3 Flash id", () => { + const cliAliases = CLI_TOOLS.antigravity.modelAliases; + const cliModelIds = CLI_TOOLS.antigravity.defaultModels.map((model) => model.id); + const agyFreeModelIds = FREE_MODEL_BUDGETS.filter((model) => model.provider === "agy").map( + (model) => model.modelId + ); + const pricing = getDefaultPricing().ag; + + assert.equal(cliAliases.includes("gemini-3-flash"), false); + assert.equal(cliModelIds.includes("gemini-3-flash"), false); + assert.equal(agyFreeModelIds.includes("gemini-3-flash"), false); + assert.equal(pricing["gemini-3-flash"], undefined); + + for (const modelId of ACTIVE_FLASH_MODEL_IDS) { + assert.equal(cliAliases.includes(modelId), true, `${modelId} must remain selectable`); + assert.equal(cliModelIds.includes(modelId), true, `${modelId} must remain a CLI default`); + assert.equal(agyFreeModelIds.includes(modelId), true, `${modelId} must remain in AGY metadata`); + assert.ok(pricing[modelId], `${modelId} must retain Antigravity pricing`); + } +}); + +test("Antigravity does not retain routing aliases for confirmed retired models", () => { + assert.equal(Object.hasOwn(ANTIGRAVITY_MODEL_ALIASES, "gemini-3-pro-preview"), false); + assert.equal( + Object.hasOwn(ANTIGRAVITY_MODEL_ALIASES, "gemini-2.5-computer-use-preview-10-2025"), + false + ); + assert.equal(Object.hasOwn(ANTIGRAVITY_REVERSE_MODEL_ALIASES, "gemini-3.1-pro"), false); + assert.equal(Object.hasOwn(ANTIGRAVITY_REVERSE_MODEL_ALIASES, "rev19-uic3-1p"), false); + + assert.equal(resolveAntigravityModelId("gemini-3-pro-preview"), "gemini-3-pro-preview"); + assert.equal( + resolveAntigravityModelId("gemini-2.5-computer-use-preview-10-2025"), + "gemini-2.5-computer-use-preview-10-2025" + ); + assert.equal(toClientAntigravityModelId("gemini-3.1-pro"), "gemini-3.1-pro"); + assert.equal(toClientAntigravityModelId("rev19-uic3-1p"), "rev19-uic3-1p"); +}); diff --git a/tests/unit/antigravity-safetysettings-5003.test.ts b/tests/unit/antigravity-safetysettings-5003.test.ts index f4cc058d15..a2dddfb924 100644 --- a/tests/unit/antigravity-safetysettings-5003.test.ts +++ b/tests/unit/antigravity-safetysettings-5003.test.ts @@ -2,17 +2,12 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts"; -import { DEFAULT_SAFETY_SETTINGS } from "../../open-sse/translator/helpers/geminiHelper.ts"; import { openaiToAntigravityRequest } from "../../open-sse/translator/request/openai-to-gemini.ts"; -// Regression for #5003: the Antigravity (Google Cloud Code) request builder explicitly set -// `safetySettings: undefined`, which `JSON.stringify` drops entirely. With no safetySettings -// reaching Cloud Code, Google applies its server-side safety defaults that false-flag benign -// technical prompts as `prohibited_content` (HTTP 200 with a blocked body that combo failover -// treats as terminal). Antigravity still needs explicit all-OFF safety settings, -// but Cloud Code rejects HARM_CATEGORY_CIVIC_INTEGRITY on the v1internal endpoint. +// Safety policy belongs to the caller and provider. OmniRoute may remove categories the +// Cloud Code endpoint rejects, but it must not silently weaken safety by synthesizing all-OFF. -test("transformRequest defaults safetySettings to all-OFF when none supplied (#5003)", async () => { +test("transformRequest omits safetySettings when none are supplied", async () => { const executor = new AntigravityExecutor(); const body = { request: { @@ -27,13 +22,10 @@ test("transformRequest defaults safetySettings to all-OFF when none supplied (#5 if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); const innerRequest = result.request as Record; - const antigravitySafetySettings = DEFAULT_SAFETY_SETTINGS.filter( - (setting) => setting.category !== "HARM_CATEGORY_CIVIC_INTEGRITY" - ); - assert.deepEqual( - innerRequest.safetySettings, - antigravitySafetySettings, - "safetySettings must default to all-OFF entries accepted by Cloud Code" + assert.equal( + "safetySettings" in innerRequest, + false, + "missing caller safety settings must stay absent" ); }); @@ -64,6 +56,21 @@ test("transformRequest honors caller-supplied safetySettings accepted by Cloud C ); }); +test("OpenAI Antigravity translation omits safetySettings when the caller omits them", () => { + const translated = openaiToAntigravityRequest( + "gemini-2.5-flash", + { messages: [{ role: "user", content: "hi" }] }, + true, + { projectId: "project-1" } + ); + + assert.equal( + translated.request.safetySettings, + undefined, + "generic Gemini safety defaults must not leak into the Antigravity envelope" + ); +}); + test("OpenAI Antigravity translation preserves caller-supplied safetySettings (#5003)", async () => { const executor = new AntigravityExecutor(); const callerSafety = [ diff --git a/tests/unit/antigravity-server-side-tools-6914.test.ts b/tests/unit/antigravity-server-side-tools-6914.test.ts index 73dfd18d7b..cd04989e9c 100644 --- a/tests/unit/antigravity-server-side-tools-6914.test.ts +++ b/tests/unit/antigravity-server-side-tools-6914.test.ts @@ -1,13 +1,17 @@ -// #6914: server-side tool invocations must be requested from Antigravity whenever the -// caller sends tools, and must NOT be forced when the request carries none. Lives in -// its own file (not executor-antigravity.test.ts) because that suite is frozen at the -// test-file-size cap. +// #6914 (revised by #8098 protocol fidelity): the real Antigravity client does NOT +// synthesize an `includeServerSideToolInvocations` flag on toolConfig, so OmniRoute must +// not either — sending a flag the native client never sends breaks protocol fidelity. +// When tools are present the request still carries `functionCallingConfig.mode = "VALIDATED"` +// (and NO synthetic server-side flag); when no tools are present toolConfig stays absent. +// Server-side tool-call cloaking is covered separately by +// antigravity-tool-cloak-server-side-invocations.test.ts. Lives in its own file (not +// executor-antigravity.test.ts) because that suite is frozen at the test-file-size cap. import { test } from "node:test"; import assert from "node:assert/strict"; import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts"; -test("AntigravityExecutor.transformRequest includes includeServerSideToolInvocations when tools are present", async () => { +test("AntigravityExecutor.transformRequest sets VALIDATED mode without a synthetic server-side flag when tools are present", async () => { const executor = new AntigravityExecutor(); const body = { request: { @@ -22,11 +26,11 @@ test("AntigravityExecutor.transformRequest includes includeServerSideToolInvocat if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); assert.deepEqual(result.request.toolConfig, { - functionCallingConfig: { mode: "VALIDATED", includeServerSideToolInvocations: true }, + functionCallingConfig: { mode: "VALIDATED" }, }); }); -test("AntigravityExecutor.transformRequest does not include includeServerSideToolInvocations when no tools", async () => { +test("AntigravityExecutor.transformRequest does not include a toolConfig when no tools", async () => { const executor = new AntigravityExecutor(); const body = { request: { diff --git a/tests/unit/antigravity-streaming-error-body-sanitized-2461.test.ts b/tests/unit/antigravity-streaming-error-body-sanitized-2461.test.ts index 41d242c1f5..27847aa3e4 100644 --- a/tests/unit/antigravity-streaming-error-body-sanitized-2461.test.ts +++ b/tests/unit/antigravity-streaming-error-body-sanitized-2461.test.ts @@ -3,8 +3,9 @@ import assert from "node:assert/strict"; import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts"; import { - clearAntigravityVersionCache, - seedAntigravityVersionCache, + clearAntigravityVersionCaches, + seedAntigravityIdeVersionCache, + seedAntigravityCliVersionCache, } from "../../open-sse/services/antigravityVersion.ts"; // Ports decolua/9router#2461: a non-ok (e.g. 403) Antigravity upstream response in the @@ -15,13 +16,14 @@ import { // end up surfaced verbatim in the client-visible error message, corrupting it (reporters // saw literal control-byte garbage after "[ERROR] [403]:"). test.afterEach(() => { - clearAntigravityVersionCache(); + clearAntigravityVersionCaches(); }); test("AntigravityExecutor.execute (stream=true) sanitizes a non-ok upstream body instead of piping raw bytes", async () => { const executor = new AntigravityExecutor(); const originalFetch = globalThis.fetch; - seedAntigravityVersionCache("2026.04.17-test"); + seedAntigravityIdeVersionCache("2026.04.17-test"); + seedAntigravityCliVersionCache("2026.04.17-test"); // Simulate a gzip-compressed 403 body (magic bytes 0x1f 0x8b), the exact shape // reported upstream — reading it as text without decoding produces garbage. diff --git a/tests/unit/antigravity-streaming-passthrough.test.ts b/tests/unit/antigravity-streaming-passthrough.test.ts index 15eb654a35..88702823b2 100644 --- a/tests/unit/antigravity-streaming-passthrough.test.ts +++ b/tests/unit/antigravity-streaming-passthrough.test.ts @@ -9,8 +9,9 @@ import { createCreditsExtractionTransform, } from "../../open-sse/executors/antigravity.ts"; import { - clearAntigravityVersionCache, - seedAntigravityVersionCache, + clearAntigravityVersionCaches, + seedAntigravityIdeVersionCache, + seedAntigravityCliVersionCache, } from "../../open-sse/services/antigravityVersion.ts"; type ChatCompletionPayload = { @@ -27,7 +28,7 @@ type ChatCompletionPayload = { }; test.afterEach(() => { - clearAntigravityVersionCache(); + clearAntigravityVersionCaches(); }); test("AntigravityExecutor.execute auto-retries short 429 responses and collects SSE for non-stream clients", async () => { @@ -35,7 +36,8 @@ test("AntigravityExecutor.execute auto-retries short 429 responses and collects const originalFetch = globalThis.fetch; const originalSetTimeout = globalThis.setTimeout; const calls = []; - seedAntigravityVersionCache("2026.04.17-test"); + seedAntigravityIdeVersionCache("2026.04.17-test"); + seedAntigravityCliVersionCache("2026.04.17-test"); globalThis.fetch = async (url) => { calls.push(String(url)); diff --git a/tests/unit/antigravity-tool-cloak-server-side-invocations.test.ts b/tests/unit/antigravity-tool-cloak-server-side-invocations.test.ts index 1bcb0834d0..cb10dd5bc4 100644 --- a/tests/unit/antigravity-tool-cloak-server-side-invocations.test.ts +++ b/tests/unit/antigravity-tool-cloak-server-side-invocations.test.ts @@ -1,9 +1,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { openaiToAntigravityRequest } from "../../open-sse/translator/request/openai-to-gemini.ts"; -import { cloakAntigravityToolPayload } from "../../open-sse/config/toolCloaking.ts"; -test("Antigravity payload sets include_server_side_tool_invocations when built-in-shaped decoy tools are injected (#6914)", () => { +import { sanitizeAntigravityToolPayload } from "../../open-sse/config/toolCloaking.ts"; +import { openaiToAntigravityRequest } from "../../open-sse/translator/request/openai-to-gemini.ts"; + +test("Antigravity payload does not synthesize decoys or server-side tool invocation flags", () => { const body = { model: "gemini-pro-agent", messages: [{ role: "user", content: "list files in the repo" }], @@ -21,41 +22,22 @@ test("Antigravity payload sets include_server_side_tool_invocations when built-i const envelope = openaiToAntigravityRequest("gemini-pro-agent", body, false, { projectId: "test-project", }); - const { body: cloaked } = cloakAntigravityToolPayload(envelope as Record); - const serialized = JSON.stringify(cloaked); - const BUILTIN_SHAPED_DECOY_NAMES = [ - "search_web", - "browser_subagent", - "read_url_content", - "generate_image", - ]; - const injectedBuiltinShapedDecoys = BUILTIN_SHAPED_DECOY_NAMES.filter((name) => - serialized.includes(`"${name}"`) - ); - const hasOptInFlag = - serialized.includes("include_server_side_tool_invocations") || - serialized.includes("includeServerSideToolInvocations"); - - assert.notDeepEqual(injectedBuiltinShapedDecoys, [], "expected decoy tools to be injected"); - assert.equal( - hasOptInFlag, - true, - "expected the includeServerSideToolInvocations opt-in flag to be set alongside decoy tools" - ); -}); - -test("Antigravity payload does not set the opt-in flag when no decoys are injected", () => { - const body = { - model: "gemini-pro-agent", - messages: [{ role: "user", content: "hello" }], + const sanitized = sanitizeAntigravityToolPayload(envelope as Record); + const request = sanitized.request as { + tools?: Array<{ functionDeclarations?: Array<{ name: string }> }>; + toolConfig?: Record; }; - const envelope = openaiToAntigravityRequest("gemini-pro-agent", body, false, { - projectId: "test-project", - }); - const { body: cloaked } = cloakAntigravityToolPayload(envelope as Record); - const serialized = JSON.stringify(cloaked); - const hasOptInFlag = - serialized.includes("include_server_side_tool_invocations") || - serialized.includes("includeServerSideToolInvocations"); - assert.equal(hasOptInFlag, false, "flag should not be set when no tools/decoys are present"); + const declarationNames = + request.tools?.flatMap((tool) => tool.functionDeclarations?.map((item) => item.name) ?? []) ?? + []; + + assert.deepEqual(declarationNames, ["bash"]); + assert.equal( + declarationNames.some((name) => name.endsWith("_ide")), + false + ); + assert.equal(declarationNames.includes("browser_subagent"), false); + assert.equal(declarationNames.includes("mcp_sequential_thinking_sequentialthinking"), false); + assert.equal(request.toolConfig?.includeServerSideToolInvocations, undefined); + assert.equal(request.toolConfig?.include_server_side_tool_invocations, undefined); }); diff --git a/tests/unit/antigravity-tool-cloaking.test.ts b/tests/unit/antigravity-tool-cloaking.test.ts index 3770eab655..f05c058dba 100644 --- a/tests/unit/antigravity-tool-cloaking.test.ts +++ b/tests/unit/antigravity-tool-cloaking.test.ts @@ -2,13 +2,25 @@ import test from "node:test"; import assert from "node:assert/strict"; import { - AG_DECOY_TOOLS, - AG_TOOL_SUFFIX, - cloakAntigravityToolPayload, + sanitizeAntigravityToolPayload, stripEnumDescriptions, } from "../../open-sse/config/toolCloaking.ts"; -test("cloakAntigravityToolPayload cloaks custom tools, preserves native tools and injects decoys", () => { +type ToolDeclaration = { + name: string; + parameters?: Record; +}; + +function hasKeyDeep(value: unknown, key: string): boolean { + if (!value || typeof value !== "object") return false; + if (Array.isArray(value)) return value.some((entry) => hasKeyDeep(entry, key)); + const record = value as Record; + return ( + Object.hasOwn(record, key) || Object.values(record).some((entry) => hasKeyDeep(entry, key)) + ); +} + +test("Antigravity tool sanitization preserves declared and historical tool names", () => { const payload = { request: { tools: [ @@ -21,7 +33,7 @@ test("cloakAntigravityToolPayload cloaks custom tools, preserves native tools an }, { name: "run_command", - description: "Native tool should stay visible", + description: "Run a command", parameters: { type: "OBJECT", properties: {} }, }, ], @@ -40,57 +52,29 @@ test("cloakAntigravityToolPayload cloaks custom tools, preserves native tools an }, }; - const result = cloakAntigravityToolPayload(payload); - const declarations = (result.body.request.tools?.[0] as any)?.functionDeclarations || []; - const names = declarations.map((tool: { name: string }) => tool.name); + const result = sanitizeAntigravityToolPayload(payload); + const declarations = result.request.tools[0].functionDeclarations; - assert.ok(names.includes(`workspace_read${AG_TOOL_SUFFIX}`)); - assert.ok(names.includes("run_command")); - assert.ok(names.includes("browser_subagent")); - assert.ok(names.includes("mcp_sequential_thinking_sequentialthinking")); - for (const name of names) { - assert.match(name, /^[a-zA-Z0-9_]+$/); - } - assert.equal( - result.body.request.contents[0].parts[0].functionCall.name, - `workspace_read${AG_TOOL_SUFFIX}` + assert.deepEqual( + declarations.map((tool: ToolDeclaration) => tool.name), + ["workspace_read", "run_command"], + "only client-declared tools should remain, in their original order" ); assert.equal( - result.body.request.contents[1].parts[0].functionResponse.name, - `workspace_read${AG_TOOL_SUFFIX}` + result.request.contents[0].parts[0].functionCall.name, + "workspace_read", + "functionCall names must remain aligned with declarations" ); - assert.equal(result.toolNameMap?.get(`workspace_read${AG_TOOL_SUFFIX}`), "workspace_read"); assert.equal( - declarations.filter((tool: { name: string }) => tool.name === "browser_subagent").length, - 1 + result.request.contents[1].parts[0].functionResponse.name, + "workspace_read", + "functionResponse names must remain aligned with declarations" ); - assert.ok(AG_DECOY_TOOLS.length > 20); -}); - -test("cloakAntigravityToolPayload composes namespace sanitization maps with Antigravity cloaking", () => { - const payload = { - _toolNameMap: new Map([["workspace_read", "mcp__filesystem__workspace_read"]]), - request: { - tools: [ - { - functionDeclarations: [ - { - name: "workspace_read", - description: "Read a file", - parameters: { type: "OBJECT", properties: {} }, - }, - ], - }, - ], - contents: [], - }, - }; - - const result = cloakAntigravityToolPayload(payload); - + assert.equal("_toolNameMap" in result, false, "sanitization must not create a reverse cloak map"); assert.equal( - result.toolNameMap?.get(`workspace_read${AG_TOOL_SUFFIX}`), - "mcp__filesystem__workspace_read" + "includeServerSideToolInvocations" in result.request, + false, + "sanitization must not opt into undeclared server-side tools" ); }); @@ -121,21 +105,28 @@ test("stripEnumDescriptions removes enumDescriptions at every nesting level", () }, }, }, + anyOf: [{ type: "STRING", enumDescriptions: ["anyOf desc"] }], + allOf: [{ oneOf: [{ type: "NUMBER", enumDescriptions: ["oneOf desc"] }] }], + $defs: { + shared: { type: "BOOLEAN", enumDescriptions: ["definition desc"] }, + }, + additionalProperties: { + type: "STRING", + enumDescriptions: ["additionalProperties desc"], + }, }; - const stripped = stripEnumDescriptions(schema) as any; + const stripped = stripEnumDescriptions(schema) as Record; - assert.equal("enumDescriptions" in stripped, false); - assert.equal("enumDescriptions" in stripped.properties.mode, false); - assert.equal("enumDescriptions" in stripped.properties.nested.properties.choice, false); - assert.equal("enumDescriptions" in stripped.properties.list.items, false); - // Non-target fields are preserved. - assert.deepEqual(stripped.properties.mode.enum, ["a", "b"]); - // Input is not mutated. - assert.ok(Array.isArray((schema.properties.mode as any).enumDescriptions)); + assert.equal(hasKeyDeep(stripped, "enumDescriptions"), false); + assert.deepEqual( + ((stripped.properties as Record).mode as Record).enum, + ["a", "b"] + ); + assert.ok(Array.isArray(schema.properties.mode.enumDescriptions), "input must not be mutated"); }); -test("cloakAntigravityToolPayload strips enumDescriptions from declaration parameters", () => { +test("Antigravity tool sanitization strips enumDescriptions without changing declarations", () => { const payload = { request: { tools: [ @@ -156,19 +147,6 @@ test("cloakAntigravityToolPayload strips enumDescriptions from declaration param }, }, }, - { - name: "run_command", - description: "Native tool keeps visible name but loses enumDescriptions", - parameters: { - type: "OBJECT", - properties: { - shell: { - type: "STRING", - enumDescriptions: ["bash", "zsh"], - }, - }, - }, - }, ], }, ], @@ -176,18 +154,15 @@ test("cloakAntigravityToolPayload strips enumDescriptions from declaration param }, }; - const result = cloakAntigravityToolPayload(payload); - const declarations = (result.body.request.tools?.[0] as any)?.functionDeclarations || []; + const result = sanitizeAntigravityToolPayload(payload); + const declaration = result.request.tools[0].functionDeclarations[0] as ToolDeclaration; + const parameters = declaration.parameters as { + enumDescriptions?: unknown; + properties: { mode: { enumDescriptions?: unknown; enum: string[] } }; + }; - const cloaked = declarations.find( - (d: { name: string }) => d.name === `workspace_read${AG_TOOL_SUFFIX}` - ); - assert.ok(cloaked, "cloaked client tool present"); - assert.equal("enumDescriptions" in cloaked.parameters, false); - assert.equal("enumDescriptions" in cloaked.parameters.properties.mode, false); - assert.deepEqual(cloaked.parameters.properties.mode.enum, ["read", "write"]); - - const native = declarations.find((d: { name: string }) => d.name === "run_command"); - assert.ok(native, "native tool preserved"); - assert.equal("enumDescriptions" in native.parameters.properties.shell, false); + assert.equal(declaration.name, "workspace_read"); + assert.equal("enumDescriptions" in parameters, false); + assert.equal("enumDescriptions" in parameters.properties.mode, false); + assert.deepEqual(parameters.properties.mode.enum, ["read", "write"]); }); diff --git a/tests/unit/antigravity-usage-fetcher.test.ts b/tests/unit/antigravity-usage-fetcher.test.ts index b53e482d53..66bb87b6ef 100644 --- a/tests/unit/antigravity-usage-fetcher.test.ts +++ b/tests/unit/antigravity-usage-fetcher.test.ts @@ -282,4 +282,51 @@ describe("getUsageForProvider (antigravity in fetcher.ts)", () => { mockFetch.mock.restore(); } }); + + it("does not proactively spend Google One AI credits in retry mode", async () => { + const previousCreditsMode = process.env.ANTIGRAVITY_CREDITS; + const calls: Array<{ url: string; body: string }> = []; + const fetcherModule = await import("../../src/lib/usage/fetcher.ts"); + const { getUsageForProvider } = fetcherModule; + + const mockFetch = mock.method(global, "fetch", async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + calls.push({ url, body: typeof init?.body === "string" ? init.body : "" }); + + if (url.includes(":streamGenerateContent")) { + return new Response("data: [DONE]\n\n", { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + + if (url.includes(":fetchAvailableModels")) { + return new Response(JSON.stringify({ models: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + throw new Error(`Unexpected fetch: ${url}`); + }); + + try { + process.env.ANTIGRAVITY_CREDITS = "retry"; + await getUsageForProvider({ + ...connectionBase, + id: "retry-mode-no-proactive-probe", + projectId: "test-project", + }); + + assert.equal(calls.filter((call) => call.url.includes(":streamGenerateContent")).length, 0); + assert.equal(calls.filter((call) => call.url.includes(":fetchAvailableModels")).length, 1); + } finally { + mockFetch.mock.restore(); + if (previousCreditsMode === undefined) { + delete process.env.ANTIGRAVITY_CREDITS; + } else { + process.env.ANTIGRAVITY_CREDITS = previousCreditsMode; + } + } + }); }); diff --git a/tests/unit/antigravity-usage-service.test.ts b/tests/unit/antigravity-usage-service.test.ts index 30d1f0d6b8..66bbb64a14 100644 --- a/tests/unit/antigravity-usage-service.test.ts +++ b/tests/unit/antigravity-usage-service.test.ts @@ -35,7 +35,7 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { ok: true, json: async () => ({ models: { - "gemini-3.5-flash-high": { + "gemini-3-flash-agent": { quotaInfo: { remainingFraction: undefined, resetTime: "2026-05-26T00:00:00Z", @@ -51,8 +51,8 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { assert.ok("quotas" in result, "should have quotas"); if ("quotas" in result) { - const quota = result.quotas["gemini-3.5-flash-high"]; - assert.ok(quota, "should have quota for gemini-3.5-flash-high"); + const quota = result.quotas["gemini-3-flash-agent"]; + assert.ok(quota, "should have quota for gemini-3-flash-agent"); assert.equal(quota.remainingPercentage, 0, "remaining should be 0%"); assert.equal(quota.unlimited, false, "should not be unlimited"); assert.equal(quota.used > 0, true, "used should be > 0 when quota is exhausted"); @@ -69,7 +69,7 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { ok: true, json: async () => ({ models: { - "gemini-3.5-flash-high": { + "gemini-3-flash-agent": { quotaInfo: { remainingFraction: 0, resetTime: "2026-05-26T00:00:00Z", @@ -88,8 +88,8 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { assert.ok("quotas" in result, "should have quotas"); if ("quotas" in result) { - const quota = result.quotas["gemini-3.5-flash-high"]; - assert.ok(quota, "should have quota for gemini-3.5-flash-high"); + const quota = result.quotas["gemini-3-flash-agent"]; + assert.ok(quota, "should have quota for gemini-3-flash-agent"); assert.equal(quota.remainingPercentage, 0, "remaining should be 0%"); assert.equal(quota.unlimited, false, "should not be unlimited"); } @@ -105,7 +105,7 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { ok: true, json: async () => ({ models: { - "gemini-3.5-flash-high": { + "gemini-3-flash-agent": { quotaInfo: { remainingFraction: 1.0, resetTime: "2026-05-26T00:00:00Z", @@ -124,8 +124,8 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { assert.ok("quotas" in result, "should have quotas"); if ("quotas" in result) { - const quota = result.quotas["gemini-3.5-flash-high"]; - assert.ok(quota, "should have quota for gemini-3.5-flash-high"); + const quota = result.quotas["gemini-3-flash-agent"]; + assert.ok(quota, "should have quota for gemini-3-flash-agent"); assert.equal(quota.remainingPercentage, 100, "remaining should be 100%"); assert.equal(quota.unlimited, false, "should not be unlimited (has resetTime)"); } @@ -176,7 +176,7 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { ok: true, json: async () => ({ models: { - "gemini-3.5-flash-high": { + "gemini-3-flash-agent": { quotaInfo: { remainingFraction: 0.5, resetTime: "2026-05-26T00:00:00Z", @@ -195,8 +195,8 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { assert.ok("quotas" in result, "should have quotas"); if ("quotas" in result) { - const quota = result.quotas["gemini-3.5-flash-high"]; - assert.ok(quota, "should have quota for gemini-3.5-flash-high"); + const quota = result.quotas["gemini-3-flash-agent"]; + assert.ok(quota, "should have quota for gemini-3-flash-agent"); assert.equal(quota.remainingPercentage, 50, "remaining should be 50%"); assert.equal(quota.unlimited, false, "should not be unlimited"); } @@ -212,7 +212,7 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { ok: true, json: async () => ({ models: { - "gemini-3.5-flash-high": { + "gemini-3-flash-agent": { quotaInfo: { remainingFraction: 1.5, resetTime: "2026-05-26T00:00:00Z", @@ -231,8 +231,8 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { assert.ok("quotas" in result, "should have quotas"); if ("quotas" in result) { - const quota = result.quotas["gemini-3.5-flash-high"]; - assert.ok(quota, "should have quota for gemini-3.5-flash-high"); + const quota = result.quotas["gemini-3-flash-agent"]; + assert.ok(quota, "should have quota for gemini-3-flash-agent"); assert.equal(quota.remainingPercentage, 100, "remaining should be clamped to 100%"); assert.equal(quota.unlimited, false, "should not be unlimited (has resetTime)"); } @@ -248,7 +248,7 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { ok: true, json: async () => ({ models: { - "gemini-3.5-flash-high": { + "gemini-3-flash-agent": { quotaInfo: { remainingFraction: -0.5, resetTime: "2026-05-26T00:00:00Z", @@ -267,8 +267,8 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { assert.ok("quotas" in result, "should have quotas"); if ("quotas" in result) { - const quota = result.quotas["gemini-3.5-flash-high"]; - assert.ok(quota, "should have quota for gemini-3.5-flash-high"); + const quota = result.quotas["gemini-3-flash-agent"]; + assert.ok(quota, "should have quota for gemini-3-flash-agent"); assert.equal(quota.remainingPercentage, 0, "remaining should be clamped to 0%"); assert.equal(quota.unlimited, false, "should not be unlimited"); } diff --git a/tests/unit/antigravity-version.test.ts b/tests/unit/antigravity-version.test.ts index d1c0bae454..cf94ab6adf 100644 --- a/tests/unit/antigravity-version.test.ts +++ b/tests/unit/antigravity-version.test.ts @@ -1,94 +1,193 @@ -import test from "node:test"; import assert from "node:assert/strict"; +import test from "node:test"; import { - ANTIGRAVITY_FALLBACK_VERSION, + ANTIGRAVITY_CLI_FALLBACK_VERSION, + ANTIGRAVITY_IDE_FALLBACK_VERSION, ANTIGRAVITY_VERSION_CACHE_TTL_MS, - clearAntigravityVersionCache, - getCachedAntigravityVersion, - resolveAntigravityVersion, - seedAntigravityVersionCache, + clearAntigravityVersionCaches, + getCachedAntigravityCliVersion, + getCachedAntigravityIdeVersion, + resolveAntigravityCliVersion, + resolveAntigravityIdeVersion, + seedAntigravityCliVersionCache, + seedAntigravityIdeVersionCache, } from "../../open-sse/services/antigravityVersion.ts"; const originalDateNow = Date.now; test.afterEach(() => { Date.now = originalDateNow; - clearAntigravityVersionCache(); + clearAntigravityVersionCaches(); }); -test("resolveAntigravityVersion uses the official release feed and caches the result for 6 hours", async () => { - let calls = 0; - const fetchMock = async () => { - calls += 1; - return new Response(JSON.stringify([{ version: "4.2.0", execution_id: "4781536860569600" }]), { +test("IDE and CLI start with independent captured fallback versions", () => { + assert.equal(getCachedAntigravityIdeVersion(), ANTIGRAVITY_IDE_FALLBACK_VERSION); + assert.equal(getCachedAntigravityCliVersion(), ANTIGRAVITY_CLI_FALLBACK_VERSION); + assert.equal(ANTIGRAVITY_IDE_FALLBACK_VERSION, "2.1.1"); + assert.equal(ANTIGRAVITY_CLI_FALLBACK_VERSION, "1.1.5"); +}); + +test("IDE resolver reads the official updater feed and caches only the IDE version", async () => { + const urls: string[] = []; + const fetchMock = async (url: string | URL | Request) => { + urls.push(String(url)); + return new Response(JSON.stringify([{ version: "2.2.0", execution_id: "ide-release" }]), { status: 200, headers: { "Content-Type": "application/json" }, }); }; - const first = await resolveAntigravityVersion(fetchMock as typeof fetch); - const second = await resolveAntigravityVersion(fetchMock as typeof fetch); - - assert.equal(first, "4.2.0"); - assert.equal(second, "4.2.0"); - assert.equal(calls, 1); - assert.equal(getCachedAntigravityVersion(), "4.2.0"); + assert.equal(await resolveAntigravityIdeVersion(fetchMock as typeof fetch), "2.2.0"); + assert.equal(await resolveAntigravityIdeVersion(fetchMock as typeof fetch), "2.2.0"); + assert.equal(urls.length, 1); + assert.equal( + urls[0], + "https://antigravity-auto-updater-974169037036.us-central1.run.app/releases" + ); + assert.equal(getCachedAntigravityIdeVersion(), "2.2.0"); + assert.equal(getCachedAntigravityCliVersion(), "1.1.5"); }); -test("resolveAntigravityVersion refreshes the cache after the TTL elapses", async () => { +test("IDE resolver selects the newest feed entry and never falls below its version floor", async () => { + const mixedFeedFetch = async () => + new Response( + JSON.stringify([ + { version: "2.0.0" }, + { version: "2.4.0" }, + { version: "2.2.0" }, + { version: "invalid" }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + + assert.equal(await resolveAntigravityIdeVersion(mixedFeedFetch as typeof fetch), "2.4.0"); + + clearAntigravityVersionCaches(); + const staleFeedFetch = async () => + new Response(JSON.stringify([{ version: "2.0.0" }, { version: "1.23.2" }]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + assert.equal( + await resolveAntigravityIdeVersion(staleFeedFetch as typeof fetch), + ANTIGRAVITY_IDE_FALLBACK_VERSION + ); + assert.equal(getCachedAntigravityIdeVersion(), ANTIGRAVITY_IDE_FALLBACK_VERSION); +}); + +test("IDE resolver keeps a newer cached version when a later feed response is older", async () => { let now = 1_000; Date.now = () => now; + seedAntigravityIdeVersionCache("2.5.0", now); + now += ANTIGRAVITY_VERSION_CACHE_TTL_MS + 1; + + const olderFeedFetch = async () => + new Response(JSON.stringify([{ version: "2.4.0" }]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + assert.equal(await resolveAntigravityIdeVersion(olderFeedFetch as typeof fetch), "2.5.0"); + assert.equal(getCachedAntigravityIdeVersion(), "2.5.0"); +}); + +test("CLI resolver reads the official Google GitHub release and caches only the CLI version", async () => { + const urls: string[] = []; + const fetchMock = async (url: string | URL | Request) => { + urls.push(String(url)); + return new Response(JSON.stringify({ tag_name: "v1.2.0", name: "1.2.0" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + assert.equal(await resolveAntigravityCliVersion(fetchMock as typeof fetch), "1.2.0"); + assert.equal(await resolveAntigravityCliVersion(fetchMock as typeof fetch), "1.2.0"); + assert.equal(urls.length, 1); + assert.equal( + urls[0], + "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/latest" + ); + assert.equal(getCachedAntigravityCliVersion(), "1.2.0"); + assert.equal(getCachedAntigravityIdeVersion(), "2.1.1"); +}); + +test("IDE and CLI TTL refreshes are independent", async () => { + let now = 1_000; + Date.now = () => now; + seedAntigravityCliVersionCache("1.1.5", now); const firstFetch = async () => - new Response(JSON.stringify([{ version: "4.2.0" }]), { + new Response(JSON.stringify([{ version: "2.2.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, }); const secondFetch = async () => - new Response(JSON.stringify([{ version: "4.3.0" }]), { + new Response(JSON.stringify([{ version: "2.3.0" }]), { status: 200, headers: { "Content-Type": "application/json" }, }); - assert.equal(await resolveAntigravityVersion(firstFetch as typeof fetch), "4.2.0"); - + assert.equal(await resolveAntigravityIdeVersion(firstFetch as typeof fetch), "2.2.0"); now += ANTIGRAVITY_VERSION_CACHE_TTL_MS + 1; - - assert.equal(await resolveAntigravityVersion(secondFetch as typeof fetch), "4.3.0"); - assert.equal(getCachedAntigravityVersion(), "4.3.0"); + assert.equal(await resolveAntigravityIdeVersion(secondFetch as typeof fetch), "2.3.0"); + assert.equal(getCachedAntigravityCliVersion(), "1.1.5"); }); -test("resolveAntigravityVersion falls back to the last known good version or bundled fallback", async () => { +test("each resolver falls back only to its own last known good version", async () => { const failingFetch = async () => { throw new Error("network down"); }; assert.equal( - await resolveAntigravityVersion(failingFetch as typeof fetch), - ANTIGRAVITY_FALLBACK_VERSION + await resolveAntigravityIdeVersion(failingFetch as typeof fetch), + ANTIGRAVITY_IDE_FALLBACK_VERSION + ); + assert.equal( + await resolveAntigravityCliVersion(failingFetch as typeof fetch), + ANTIGRAVITY_CLI_FALLBACK_VERSION ); - seedAntigravityVersionCache("4.2.1", 0); - assert.equal(await resolveAntigravityVersion(failingFetch as typeof fetch), "4.2.1"); + seedAntigravityIdeVersionCache("2.4.0", 0); + seedAntigravityCliVersionCache("1.3.0", 0); + assert.equal(await resolveAntigravityIdeVersion(failingFetch as typeof fetch), "2.4.0"); + assert.equal(await resolveAntigravityCliVersion(failingFetch as typeof fetch), "1.3.0"); }); -test("resolveAntigravityVersion parses GitHub-style tag_name payloads with or without a v prefix", async () => { - let calls = 0; - const fetchMock = async () => { - calls += 1; - if (calls === 1) { - return new Response(JSON.stringify({ malformed: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } +test("concurrent requests coalesce within each product without crossing products", async () => { + let ideCalls = 0; + let cliCalls = 0; + let releaseIde: (() => void) | undefined; + let releaseCli: (() => void) | undefined; + const ideGate = new Promise((resolve) => { + releaseIde = resolve; + }); + const cliGate = new Promise((resolve) => { + releaseCli = resolve; + }); - return new Response(JSON.stringify({ tag_name: "v4.2.3" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + const ideFetch = async () => { + ideCalls += 1; + await ideGate; + return new Response(JSON.stringify([{ version: "2.5.0" }]), { status: 200 }); + }; + const cliFetch = async () => { + cliCalls += 1; + await cliGate; + return new Response(JSON.stringify({ tag_name: "1.4.0" }), { status: 200 }); }; - assert.equal(await resolveAntigravityVersion(fetchMock as typeof fetch), "4.2.3"); + const ideOne = resolveAntigravityIdeVersion(ideFetch as typeof fetch); + const ideTwo = resolveAntigravityIdeVersion(ideFetch as typeof fetch); + const cliOne = resolveAntigravityCliVersion(cliFetch as typeof fetch); + const cliTwo = resolveAntigravityCliVersion(cliFetch as typeof fetch); + releaseIde?.(); + releaseCli?.(); + + assert.deepEqual(await Promise.all([ideOne, ideTwo]), ["2.5.0", "2.5.0"]); + assert.deepEqual(await Promise.all([cliOne, cliTwo]), ["1.4.0", "1.4.0"]); + assert.equal(ideCalls, 1); + assert.equal(cliCalls, 1); }); diff --git a/tests/unit/antigravity-weekly-quota-4017.test.ts b/tests/unit/antigravity-weekly-quota-4017.test.ts index 894246f1b9..3f0f15fbee 100644 --- a/tests/unit/antigravity-weekly-quota-4017.test.ts +++ b/tests/unit/antigravity-weekly-quota-4017.test.ts @@ -22,9 +22,8 @@ process.env.DATA_DIR = TEST_DATA_DIR; process.env.API_KEY_SECRET = "test-ag-weekly-secret"; const core = await import("../../src/lib/db/core.ts"); -const { parseAntigravityWeeklyQuotas } = await import( - "../../open-sse/services/usage/antigravityWeeklyQuota.ts" -); +const { parseAntigravityWeeklyQuotas } = + await import("../../open-sse/services/usage/antigravityWeeklyQuota.ts"); // Load usage.ts up-front (its index.ts proxyFetch patch runs at module eval) before mocks. const usageModule = await import("../../open-sse/services/usage.ts"); const { getUsageForProvider } = usageModule; @@ -47,7 +46,15 @@ function requestUrl(input: RequestInfo | URL): string { } interface UsageResult { - quotas: Record; + quotas: Record< + string, + { + remainingPercentage?: number; + resetAt: string | null; + unlimited: boolean; + quotaSource?: string; + } + >; } test("parseAntigravityWeeklyQuotas extracts the weekly bucket per model-family group", () => { @@ -106,7 +113,12 @@ test("parseAntigravityWeeklyQuotas tolerates the quotaSummary-nested envelope", { displayName: "Gemini Models", buckets: [ - { bucketId: "weekly", displayName: "Weekly", remainingFraction: 0.5, resetTime: RESET_IN_3_DAYS }, + { + bucketId: "weekly", + displayName: "Weekly", + remainingFraction: 0.5, + resetTime: RESET_IN_3_DAYS, + }, ], }, ], @@ -122,14 +134,20 @@ test("parseAntigravityWeeklyQuotas returns {} for missing/malformed data (best-e assert.deepEqual(parseAntigravityWeeklyQuotas(null), {}); assert.deepEqual(parseAntigravityWeeklyQuotas(undefined), {}); assert.deepEqual(parseAntigravityWeeklyQuotas({}), {}); - assert.deepEqual(parseAntigravityWeeklyQuotas({ groups: [{ displayName: "Gemini Models" }] }), {}); + assert.deepEqual( + parseAntigravityWeeklyQuotas({ groups: [{ displayName: "Gemini Models" }] }), + {} + ); }); -test("getUsageForProvider(antigravity) merges weekly group quotas alongside per-model 5h quotas", async () => { +test("getUsageForProvider(antigravity) merges weekly quotas with the selected CLI identity", async () => { core.resetDbInstance(); - globalThis.fetch = (async (input: RequestInfo | URL) => { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = requestUrl(input); + const headers = new Headers(init?.headers); + assert.match(headers.get("User-Agent") ?? "", /^antigravity\/cli\/1\.1\.5 /); + assert.equal(headers.get("X-Goog-Api-Client"), null); if (url.includes("retrieveUserQuotaSummary")) { return { @@ -158,7 +176,7 @@ test("getUsageForProvider(antigravity) merges weekly group quotas alongside per- json: async () => ({ buckets: [ { - modelId: "gemini-3.5-flash-high", + modelId: "gemini-3-flash-agent", remainingFraction: 0.4, resetTime: RESET_IN_2_HOURS, }, @@ -172,7 +190,7 @@ test("getUsageForProvider(antigravity) merges weekly group quotas alongside per- ok: true, json: async () => ({ models: { - "gemini-3.5-flash-high": { + "gemini-3-flash-agent": { quotaInfo: { remainingFraction: 1.0, resetTime: RESET_IN_2_HOURS }, }, }, @@ -184,7 +202,7 @@ test("getUsageForProvider(antigravity) merges weekly group quotas alongside per- id: "conn-weekly-1", provider: "antigravity", accessToken: "fake-token-weekly-unique", - providerSpecificData: {}, + providerSpecificData: { clientProfile: "cli" }, projectId: "test-project", }; @@ -193,8 +211,8 @@ test("getUsageForProvider(antigravity) merges weekly group quotas alongside per- const quotas = (result as UsageResult).quotas; // Existing per-model 5h quota is untouched. - assert.ok(quotas["gemini-3.5-flash-high"], "per-model 5h quota still present"); - assert.equal(quotas["gemini-3.5-flash-high"].quotaSource, "retrieveUserQuota"); + assert.ok(quotas["gemini-3-flash-agent"], "per-model 5h quota still present"); + assert.equal(quotas["gemini-3-flash-agent"].quotaSource, "retrieveUserQuota"); // New weekly group quota is merged in alongside it. assert.ok(quotas.gemini_weekly, "weekly group quota merged in"); @@ -217,7 +235,7 @@ test("getUsageForProvider(antigravity) is unaffected when retrieveUserQuotaSumma ok: true, json: async () => ({ models: { - "gemini-3.5-flash-high": { + "gemini-3-flash-agent": { quotaInfo: { remainingFraction: 1.0, resetTime: RESET_IN_2_HOURS }, }, }, @@ -235,6 +253,6 @@ test("getUsageForProvider(antigravity) is unaffected when retrieveUserQuotaSumma const result = await getUsageForProvider(connection, { forceRefresh: true }); const quotas = (result as UsageResult).quotas; - assert.ok(quotas["gemini-3.5-flash-high"], "per-model quota still present without weekly data"); + assert.ok(quotas["gemini-3-flash-agent"], "per-model quota still present without weekly data"); assert.equal(quotas.gemini_weekly, undefined, "no weekly key when the RPC is unavailable"); }); diff --git a/tests/unit/chat-messages-validation-6402.test.ts b/tests/unit/chat-messages-validation-6402.test.ts index 478181e90d..e7993d40d3 100644 --- a/tests/unit/chat-messages-validation-6402.test.ts +++ b/tests/unit/chat-messages-validation-6402.test.ts @@ -176,9 +176,22 @@ for (const model of ANTIGRAVITY_GEMINI_MODELS) { }); } -test("Antigravity Gemini-family regression coverage is not accidentally empty", () => { - assert.ok( - ANTIGRAVITY_GEMINI_MODELS.length >= 20, - `expected broad Gemini-family coverage, got ${ANTIGRAVITY_GEMINI_MODELS.length}` - ); +test("Antigravity Gemini-family regression covers every current callable tier", () => { + const coveredModels = new Set(ANTIGRAVITY_GEMINI_MODELS); + for (const model of [ + "gemini-pro-agent", + "gemini-3.1-pro-low", + "gemini-3.6-flash-high", + "gemini-3.6-flash-medium", + "gemini-3.6-flash-low", + "gemini-3-flash-agent", + "gemini-3.5-flash-low", + "gemini-3.5-flash-extra-low", + "gemini-3.1-flash-lite", + "gemini-2.5-flash-thinking", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ]) { + assert.ok(coveredModels.has(model), `expected regression coverage for ${model}`); + } }); diff --git a/tests/unit/executor-antigravity-credits.test.ts b/tests/unit/executor-antigravity-credits.test.ts new file mode 100644 index 0000000000..b5fa9113f7 --- /dev/null +++ b/tests/unit/executor-antigravity-credits.test.ts @@ -0,0 +1,317 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts"; +import { + clearAntigravityVersionCaches, + seedAntigravityIdeVersionCache, + seedAntigravityCliVersionCache, +} from "../../open-sse/services/antigravityVersion.ts"; +import { shouldRetryWithCredits } from "../../open-sse/services/antigravityCredits.ts"; + +async function readRequestBody(body: BodyInit | null | undefined): Promise { + if (typeof body === "string") return body; + if (body instanceof URLSearchParams) return body.toString(); + if (body instanceof ReadableStream) { + return new Response(body).text(); + } + if (body instanceof ArrayBuffer) return new TextDecoder().decode(body); + if (ArrayBuffer.isView(body)) { + return new TextDecoder().decode( + body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) + ); + } + throw new TypeError("Unsupported request body in fetch mock"); +} + +async function withEnv( + name: string, + value: string | undefined, + fn: () => T | Promise +): Promise { + const previous = process.env[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + + try { + return await fn(); + } finally { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + } +} + +test.afterEach(() => { + clearAntigravityVersionCaches(); +}); + +test("AntigravityExecutor keeps tool-name maps internal across credits request paths", async () => { + const originalFetch = globalThis.fetch; + const longOriginal = "namespace:" + "very_long_tool_name_".repeat(5); + const toolNameMap = new Map([ + ["weather", "ns:weather"], + ["long_tool_alias", longOriginal], + ]); + seedAntigravityIdeVersionCache("2026.04.17-test"); + seedAntigravityCliVersionCache("2026.04.17-test"); + + const success = () => + new Response( + 'data: {"response":{"candidates":[{"content":{"parts":[{"text":"OK"}]},"finishReason":"STOP"}]}}\n\n', + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); + const quota429 = () => + new Response( + JSON.stringify({ + error: { + message: "Quota exhausted. Resets in 5s", + details: [{ reason: "QUOTA_EXHAUSTED" }], + }, + }), + { status: 429, headers: { "Content-Type": "application/json" } } + ); + + try { + for (const mode of ["always", "retry"] as const) { + const wireBodies: Array> = []; + globalThis.fetch = async (_url, init) => { + const wireBody = JSON.parse(await readRequestBody(init?.body)) as Record; + wireBodies.push(wireBody); + return mode === "retry" && wireBodies.length === 1 ? quota429() : success(); + }; + + const result = await withEnv("ANTIGRAVITY_CREDITS", mode, () => + new AntigravityExecutor().execute({ + model: "antigravity/gemini-2.5-flash", + body: { + _toolNameMap: toolNameMap, + request: { + contents: [{ role: "user", parts: [{ text: "hello" }] }], + tools: [ + { + functionDeclarations: [{ name: "weather" }, { name: "long_tool_alias" }], + }, + ], + }, + }, + stream: true, + credentials: { + accessToken: "tool-map-" + mode, + connectionId: "tool-map-" + mode, + projectId: "project-1", + }, + log: { debug() {}, warn() {}, info() {} }, + }) + ); + + assert.equal( + wireBodies.some((body) => "_toolNameMap" in body), + false + ); + assert.equal(JSON.stringify(wireBodies).includes("_toolNameMap"), false); + const returnedBody = result.transformedBody as Record; + assert.equal(returnedBody._toolNameMap, toolNameMap); + assert.equal(Object.keys(returnedBody).includes("_toolNameMap"), false); + assert.equal( + (returnedBody._toolNameMap as Map).get("long_tool_alias"), + longOriginal + ); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("client aborts during credits retry do not disable future credits attempts", async () => { + const originalFetch = globalThis.fetch; + const authKey = "credits-client-abort-token"; + seedAntigravityIdeVersionCache("2026.04.17-test"); + seedAntigravityCliVersionCache("2026.04.17-test"); + + try { + for (let attempt = 0; attempt < 3; attempt++) { + const controller = new AbortController(); + let calls = 0; + globalThis.fetch = async (_url, init) => { + calls++; + if (calls === 1) { + return new Response( + JSON.stringify({ + error: { + message: "Quota exhausted. Resets in 5s", + details: [{ reason: "QUOTA_EXHAUSTED" }], + }, + }), + { status: 429, headers: { "Content-Type": "application/json" } } + ); + } + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(signal.reason); + return; + } + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + }; + + const pending = withEnv("ANTIGRAVITY_CREDITS", "retry", () => + new AntigravityExecutor().execute({ + model: "antigravity/gemini-2.5-flash", + body: { request: { contents: [{ role: "user", parts: [{ text: "hello" }] }] } }, + stream: true, + credentials: { + accessToken: authKey, + connectionId: "credits-client-abort-connection", + projectId: "project-1", + }, + signal: controller.signal, + log: { debug() {}, warn() {}, info() {}, error() {} }, + }) + ); + + while (calls < 2) { + await new Promise((resolve) => setImmediate(resolve)); + } + controller.abort(new DOMException("caller disconnected", "AbortError")); + + await assert.rejects(pending, { name: "AbortError" }); + assert.equal(shouldRetryWithCredits(authKey, "retry"), true); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("AntigravityExecutor credits modes control envelopes and eligible retry counts", async (t) => { + const originalFetch = globalThis.fetch; + seedAntigravityIdeVersionCache("2026.04.17-test"); + seedAntigravityCliVersionCache("2026.04.17-test"); + + const success = () => + new Response( + 'data: {"response":{"candidates":[{"content":{"parts":[{"text":"OK"}]},"finishReason":"STOP"}]}}\n\n', + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); + const quota429 = () => + new Response( + JSON.stringify({ + error: { + message: "Quota exhausted. Resets in 5s", + details: [{ reason: "QUOTA_EXHAUSTED" }], + }, + }), + { status: 429, headers: { "Content-Type": "application/json" } } + ); + + try { + await t.test("off strips caller credits and never retries with credits", async () => { + const bodies: Array> = []; + globalThis.fetch = async (_url, init) => { + bodies.push(JSON.parse(await readRequestBody(init?.body))); + return success(); + }; + + await withEnv("ANTIGRAVITY_CREDITS", "off", () => + new AntigravityExecutor().execute({ + model: "antigravity/gemini-2.5-flash", + body: { + enabledCreditTypes: ["GOOGLE_ONE_AI"], + request: { contents: [{ role: "user", parts: [{ text: "hello" }] }] }, + }, + stream: true, + credentials: { accessToken: "credits-off-token", projectId: "project-1" }, + log: { debug() {}, warn() {} }, + }) + ); + + assert.equal(bodies.length, 1); + assert.equal(bodies[0].enabledCreditTypes, undefined); + }); + + await t.test("retry sends no credits first and injects exactly once after eligible 429", async () => { + const bodies: Array> = []; + globalThis.fetch = async (_url, init) => { + bodies.push(JSON.parse(await readRequestBody(init?.body))); + return bodies.length === 1 ? quota429() : success(); + }; + + const result = await withEnv("ANTIGRAVITY_CREDITS", "retry", () => + new AntigravityExecutor().execute({ + model: "antigravity/gemini-2.5-flash", + body: { request: { contents: [{ role: "user", parts: [{ text: "hello" }] }] } }, + stream: true, + credentials: { accessToken: "credits-retry-token", projectId: "project-1" }, + log: { debug() {}, warn() {}, info() {} }, + }) + ); + + assert.equal(result.response.status, 200); + assert.equal(bodies.length, 2); + assert.equal(bodies[0].enabledCreditTypes, undefined); + assert.deepEqual(bodies[1].enabledCreditTypes, ["GOOGLE_ONE_AI"]); + }); + + await t.test("retry injects credits at most once when quota exhaustion persists across fallbacks", async () => { + const bodies: Array> = []; + const originalSetTimeout = globalThis.setTimeout; + globalThis.fetch = async (_url, init) => { + bodies.push(JSON.parse(await readRequestBody(init?.body))); + return quota429(); + }; + globalThis.setTimeout = ((callback) => { + (callback as () => void)(); + return 0; + }) as typeof setTimeout; + + try { + const result = await withEnv("ANTIGRAVITY_CREDITS", "retry", () => + new AntigravityExecutor().execute({ + model: "antigravity/gemini-2.5-flash", + body: { request: { contents: [{ role: "user", parts: [{ text: "hello" }] }] } }, + stream: true, + credentials: { + accessToken: "credits-persistent-429-token", + connectionId: "credits-persistent-429-connection", + projectId: "project-1", + }, + log: { debug() {}, warn() {}, info() {} }, + }) + ); + + assert.equal(result.response.status, 429); + assert.equal(bodies.length, 9); + assert.equal(bodies.filter((body) => body.enabledCreditTypes !== undefined).length, 1); + assert.equal(bodies[0].enabledCreditTypes, undefined); + assert.deepEqual(bodies[1].enabledCreditTypes, ["GOOGLE_ONE_AI"]); + assert.ok(bodies.slice(2).every((body) => body.enabledCreditTypes === undefined)); + } finally { + globalThis.setTimeout = originalSetTimeout; + } + }); + + await t.test("always injects credits on the first and only successful call", async () => { + const bodies: Array> = []; + globalThis.fetch = async (_url, init) => { + bodies.push(JSON.parse(await readRequestBody(init?.body))); + return success(); + }; + + const result = await withEnv("ANTIGRAVITY_CREDITS", "always", () => + new AntigravityExecutor().execute({ + model: "antigravity/gemini-2.5-flash", + body: { request: { contents: [{ role: "user", parts: [{ text: "hello" }] }] } }, + stream: true, + credentials: { accessToken: "credits-always-token", projectId: "project-1" }, + log: { debug() {}, warn() {}, info() {} }, + }) + ); + + assert.equal(result.response.status, 200); + assert.equal(bodies.length, 1); + assert.deepEqual(bodies[0].enabledCreditTypes, ["GOOGLE_ONE_AI"]); + }); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/executor-antigravity-protocol.test.ts b/tests/unit/executor-antigravity-protocol.test.ts new file mode 100644 index 0000000000..8082f85e81 --- /dev/null +++ b/tests/unit/executor-antigravity-protocol.test.ts @@ -0,0 +1,25 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts"; + +test("AntigravityExecutor.transformRequest preserves prompt text byte-for-byte", async () => { + const executor = new AntigravityExecutor(); + const text = "OmniRoute, OpenCode, Cursor — keep this exact text 👩🏽‍💻"; + const body = { + request: { + contents: [{ role: "user", parts: [{ text }] }], + }, + }; + + const result = await executor.transformRequest("antigravity/gemini-3.1-pro", body, true, { + projectId: "project-1", + }); + + if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); + const transformedText = (result.request.contents as Array<{ parts: Array<{ text?: string }> }>)[0] + .parts[0].text; + assert.equal(transformedText, text); + assert.deepEqual(Buffer.from(transformedText ?? ""), Buffer.from(text)); + assert.equal(transformedText?.includes("‍"), text.includes("‍")); +}); diff --git a/tests/unit/executor-antigravity.test.ts b/tests/unit/executor-antigravity.test.ts index 1cdc87e6b6..f83815c223 100644 --- a/tests/unit/executor-antigravity.test.ts +++ b/tests/unit/executor-antigravity.test.ts @@ -4,10 +4,11 @@ import assert from "node:assert/strict"; import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts"; import { setCliCompatProviders } from "../../open-sse/config/cliFingerprints.ts"; import { scrubProxyAndFingerprintHeaders } from "../../open-sse/services/antigravityHeaderScrub.ts"; -import { antigravityUserAgent } from "../../open-sse/services/antigravityHeaders.ts"; +import { antigravityIdeUserAgent } from "../../open-sse/services/antigravityHeaders.ts"; import { - clearAntigravityVersionCache, - seedAntigravityVersionCache, + clearAntigravityVersionCaches, + seedAntigravityIdeVersionCache, + seedAntigravityCliVersionCache, } from "../../open-sse/services/antigravityVersion.ts"; import { clearAntigravityProjectCache } from "../../open-sse/services/antigravityProjectBootstrap.ts"; import { runWithCapture } from "../../open-sse/utils/providerRequestLogging.ts"; @@ -62,7 +63,7 @@ async function withEnv( } test.afterEach(() => { - clearAntigravityVersionCache(); + clearAntigravityVersionCaches(); }); test("AntigravityExecutor.buildUrl always targets the streaming endpoint", () => { @@ -79,11 +80,12 @@ test("AntigravityExecutor.buildUrl always targets the streaming endpoint", () => test("AntigravityExecutor.buildHeaders includes native headers without OmniRoute internals", () => { const executor = new AntigravityExecutor(); + seedAntigravityIdeVersionCache("2.1.1"); const headers = executor.buildHeaders({ accessToken: "ag-token" }, false); assert.equal(headers.Authorization, "Bearer ag-token"); assert.equal(headers.Accept, "text/event-stream"); - assert.match(headers["User-Agent"], /^Antigravity\/4\.2\.0 /); + assert.equal(headers["User-Agent"], antigravityIdeUserAgent("2.1.1")); assert.equal(headers["X-OmniRoute-Source"], undefined); }); @@ -138,18 +140,17 @@ test("AntigravityExecutor.transformRequest normalizes model, project and content "model", "userAgent", "requestType", - "enabledCreditTypes", ]); assert.equal(result.userAgent, "antigravity"); assert.match(result.requestId, /^agent\/\d+\/[0-9a-f]{8}$/); - assert.deepEqual(result.enabledCreditTypes, ["GOOGLE_ONE_AI"]); + assert.equal(result.enabledCreditTypes, undefined); assert.ok(result.request.sessionId); const request = result.request as { generationConfig?: { topK?: number; topP?: number } }; const generationConfig = request.generationConfig || {}; assert.equal(generationConfig.topK, 40); assert.equal(generationConfig.topP, 1.0); assert.deepEqual(result.request.toolConfig, { - functionCallingConfig: { mode: "VALIDATED", includeServerSideToolInvocations: true }, + functionCallingConfig: { mode: "VALIDATED" }, }); assert.deepEqual(result.request.contents[0].parts, [{ text: "keep me" }]); assert.equal(result.request.contents[1].role, "user"); @@ -182,7 +183,7 @@ test("AntigravityExecutor.transformRequest strips thinking config for Cloud Code assert.equal(generationConfig.thinkingConfig, undefined); }); -test("AntigravityExecutor.transformRequest preserves thinking config for supported Gemini models", async () => { +test("AntigravityExecutor.transformRequest preserves thinking config for gemini-pro-agent", async () => { const executor = new AntigravityExecutor(); const body = { request: { @@ -196,7 +197,7 @@ test("AntigravityExecutor.transformRequest preserves thinking config for support }, }; - const result = await executor.transformRequest("antigravity/gemini-3.1-pro-high", body, true, { + const result = await executor.transformRequest("antigravity/gemini-pro-agent", body, true, { projectId: "project-1", }); @@ -242,7 +243,7 @@ test("AntigravityExecutor.transformRequest returns a structured error response w // auto-discover it via loadCodeAssist instead of hard-failing. test("AntigravityExecutor.transformRequest auto-discovers a missing projectId via loadCodeAssist (#2334)", async () => { clearAntigravityProjectCache(); - seedAntigravityVersionCache("2026.04.17-test"); + seedAntigravityIdeVersionCache("2.1.1"); const executor = new AntigravityExecutor(); const originalFetch = globalThis.fetch; let loadCodeAssistCalled = false; @@ -284,7 +285,7 @@ test("AntigravityExecutor.transformRequest auto-discovers a missing projectId vi // structured 422 must still be returned so the dashboard can prompt a reconnect. test("AntigravityExecutor.transformRequest still 422s when loadCodeAssist finds no project (#2334)", async () => { clearAntigravityProjectCache(); - seedAntigravityVersionCache("2026.04.17-test"); + seedAntigravityIdeVersionCache("2.1.1"); const executor = new AntigravityExecutor(); const originalFetch = globalThis.fetch; @@ -621,9 +622,6 @@ test("AntigravityExecutor.refreshCredentials refreshes Google OAuth tokens", asy refreshToken: "new-refresh", expiresIn: 3600, projectId: "project-1", - // refreshCredentials preserves providerSpecificData across refresh (#2480); when the - // input has none it surfaces as `undefined`. (Test updated to match that behavior — - // it had been stale since the #2480 change added this field.) providerSpecificData: undefined, }); } finally { @@ -638,7 +636,7 @@ test("AntigravityExecutor.refreshCredentials refreshes Google OAuth tokens", asy test("AntigravityExecutor.execute embeds retryAfterMs when the upstream asks for a long wait", async () => { const executor = new AntigravityExecutor(); const originalFetch = globalThis.fetch; - seedAntigravityVersionCache("2026.04.17-test"); + seedAntigravityIdeVersionCache("2.1.1"); globalThis.fetch = async () => new Response( @@ -675,7 +673,7 @@ test("AntigravityExecutor.execute bounds a persistent short-retry 429 instead of const originalFetch = globalThis.fetch; const originalSetTimeout = globalThis.setTimeout; const calls: string[] = []; - seedAntigravityVersionCache("2026.04.17-test"); + seedAntigravityIdeVersionCache("2.1.1"); // "rate limited" classifies as rate_limited → decide429 returns 60s // (≤ LONG_RETRY_THRESHOLD_MS), i.e. the short-retry branch. A persistent 429 @@ -706,23 +704,66 @@ test("AntigravityExecutor.execute bounds a persistent short-retry 429 instead of // Returns the 429 rather than hanging. assert.equal(result.response.status, 429); - // Bounded: 3 endpoints × (1 initial + MAX_AUTO_RETRIES=3) = 12 attempts total. - assert.equal(calls.length, 12); + // Bounded: 2 live runtime endpoints × (1 initial + MAX_AUTO_RETRIES=3) = 8 attempts total. + assert.equal(calls.length, 8); - // Tried every distinct base URL before giving up. + // Tried every distinct live runtime base URL before giving up. const distinctHosts = new Set(calls.map((u) => new URL(u).host)); - assert.equal(distinctHosts.size, 3); + assert.equal(distinctHosts.size, 2); } finally { globalThis.fetch = originalFetch; globalThis.setTimeout = originalSetTimeout; } }); +test("AntigravityExecutor.execute aborts during project bootstrap without starting runtime fetch", async () => { + clearAntigravityProjectCache(); + seedAntigravityIdeVersionCache("2026.04.17-test"); + seedAntigravityCliVersionCache("2026.04.17-test"); + const executor = new AntigravityExecutor(); + const originalFetch = globalThis.fetch; + const controller = new AbortController(); + const calls: string[] = []; + + globalThis.fetch = async (url, init) => { + calls.push(String(url)); + assert.match(String(url), /:loadCodeAssist$/); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }); + }; + + try { + const pending = executor.execute({ + model: "antigravity/gemini-2.5-flash", + body: { request: { contents: [] } }, + stream: true, + credentials: { accessToken: "bootstrap-abort-token" }, + signal: controller.signal, + log: { debug() {}, warn() {} }, + }); + while (calls.length === 0) { + await new Promise((resolve) => setImmediate(resolve)); + } + controller.abort(new DOMException("caller disconnected", "AbortError")); + + await assert.rejects(pending, { name: "AbortError" }); + assert.equal(calls.length, 1); + assert.equal( + calls.some((url) => url.includes("streamGenerateContent")), + false + ); + } finally { + globalThis.fetch = originalFetch; + clearAntigravityProjectCache(); + } +}); + test("AntigravityExecutor.execute tags pre-response stalls with a fallbackable timeout code", async () => { const executor = new AntigravityExecutor(); const originalFetch = globalThis.fetch; const originalSetTimeout = globalThis.setTimeout; - seedAntigravityVersionCache("2026.04.17-test"); + seedAntigravityIdeVersionCache("2.1.1"); globalThis.fetch = async (_url, init) => { await new Promise((_resolve, reject) => { @@ -770,7 +811,7 @@ test("AntigravityExecutor.execute applies CLI fingerprint when enabled", async ( let fetchBody: Record | null = null; let prepared: unknown = null; let preparedBeforeFetch = false; - seedAntigravityVersionCache("2026.04.17-test"); + seedAntigravityIdeVersionCache("2.1.1"); setCliCompatProviders(["antigravity"]); globalThis.fetch = async (_url, init) => { @@ -779,9 +820,9 @@ test("AntigravityExecutor.execute applies CLI fingerprint when enabled", async ( const parsedBody = JSON.parse(String(init?.body)); fetchBody = parsedBody; - assert.equal(headers["User-Agent"], antigravityUserAgent("2026.04.17-test")); - assert.equal(headers["x-client-name"], "antigravity"); - assert.equal(headers["x-client-version"], "2026.04.17-test"); + assert.equal(headers["User-Agent"], antigravityIdeUserAgent("2.1.1")); + assert.equal(headers["x-client-name"], undefined); + assert.equal(headers["x-client-version"], undefined); assert.equal(headers["x-goog-user-project"], "project-1"); assert.deepEqual(Object.keys(parsedBody), [ "project", @@ -792,6 +833,7 @@ test("AntigravityExecutor.execute applies CLI fingerprint when enabled", async ( "requestType", "enabledCreditTypes", ]); + assert.deepEqual(parsedBody.enabledCreditTypes, ["GOOGLE_ONE_AI"]); return new Response( 'data: {"response":{"candidates":[{"content":{"parts":[{"text":"OK"}]},"finishReason":"STOP"}]}}\n\n', @@ -867,7 +909,7 @@ test("AntigravityExecutor.transformRequest maps Claude models through Gemini con assert.equal(result.model, "claude-sonnet-4-6"); assert.equal(result.requestType, "agent"); assert.ok(result.request.sessionId); - assert.deepEqual(result.enabledCreditTypes, ["GOOGLE_ONE_AI"]); + assert.equal(result.enabledCreditTypes, undefined); assert.deepEqual(result.request.contents, [{ role: "user", parts: [{ text: "Hello" }] }]); assert.deepEqual(result.request.systemInstruction, { role: "system", diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts index 348cd83817..9b114f0f0c 100644 --- a/tests/unit/image-generation-handler.test.ts +++ b/tests/unit/image-generation-handler.test.ts @@ -764,9 +764,9 @@ test("handleImageGeneration sends Antigravity image requests with native image_g "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent" ); assert.equal(captured.headers.Authorization, "Bearer ag-token"); - assert.equal(captured.headers["x-client-name"], "antigravity"); + assert.equal(captured.headers["x-client-name"], undefined); assert.equal(captured.headers["x-goog-user-project"], undefined); - assert.match(captured.headers["User-Agent"], /^Antigravity\//); + assert.match(captured.headers["User-Agent"], /^antigravity\/ide\/2\.1\.1 /); assert.equal(captured.headers["x-goog-api-client"], undefined); assert.equal(captured.body.project, "project-123"); assert.match(captured.body.requestId, /^image_gen\//); diff --git a/tests/unit/model-alias-seed.test.ts b/tests/unit/model-alias-seed.test.ts index e1be1cc815..e706d565a2 100644 --- a/tests/unit/model-alias-seed.test.ts +++ b/tests/unit/model-alias-seed.test.ts @@ -34,16 +34,17 @@ test("default model alias seed writes missing aliases and is idempotent", async assert.deepEqual(first.failed, []); assert.equal(first.applied.length, Object.keys(DEFAULT_MODEL_ALIAS_SEED).length); - assert.equal(aliases["gemini-3-pro-high"], "agy/gemini-3.1-pro-high"); - assert.equal(aliases["gemini-3-pro-low"], "agy/gemini-3.1-pro-low"); - assert.equal(aliases["gemini-3-pro-preview"], "agy/gemini-pro-agent"); - assert.equal(aliases["gemini-3.1-pro-preview"], "agy/gemini-pro-agent"); - assert.equal(aliases["gemini-3-flash-preview"], "agy/gemini-3.5-flash-medium"); + assert.equal(aliases["gemini-3.1-pro"], "agy/gemini-pro-agent"); + assert.equal(aliases["gemini-3-pro-high"], undefined); + assert.equal(aliases["gemini-3-pro-low"], undefined); + assert.equal(aliases["gemini-3-pro-preview"], undefined); + assert.equal(aliases["gemini-3.1-pro-preview"], undefined); + assert.equal(aliases["gemini-3-flash-preview"], undefined); - const routed = await sseModelService.getModelInfo("gemini-3-pro-high"); + const routed = await sseModelService.getModelInfo("gemini-3.1-pro"); assert.deepEqual(routed, { provider: "agy", - model: "gemini-3.1-pro-high", + model: "gemini-pro-agent", extendedContext: false, }); @@ -54,7 +55,8 @@ test("default model alias seed writes missing aliases and is idempotent", async }); test("default model alias seed preserves existing aliases and skips invalid entries", async () => { - await modelsDb.setModelAlias("gemini-3-pro-high", "custom/provider-model"); + await modelsDb.setModelAlias("gemini-3.1-pro", "custom/canonical-model"); + await modelsDb.setModelAlias("gemini-3-pro-low", "custom/legacy-model"); const warnings = []; const result = await seedDefaultModelAliases({ @@ -68,8 +70,64 @@ test("default model alias seed preserves existing aliases and skips invalid entr }); const aliases = await modelsDb.getModelAliases(); - assert.equal(aliases["gemini-3-pro-high"], "custom/provider-model"); - assert.ok(result.skipped.includes("gemini-3-pro-high")); + assert.equal(aliases["gemini-3.1-pro"], "custom/canonical-model"); + assert.equal(aliases["gemini-3-pro-low"], "custom/legacy-model"); + assert.ok(result.skipped.includes("gemini-3.1-pro")); + assert.equal(result.removed.includes("gemini-3-pro-low"), false); assert.ok(result.failed.includes("broken-entry")); assert.ok(warnings.some((message) => message.includes("broken-entry"))); }); + +test("default model alias seed replaces superseded Gemini Pro aliases with 3.1 Pro", async () => { + await modelsDb.setModelAlias("gemini-3-pro-high", "agy/gemini-3.1-pro-high"); + await modelsDb.setModelAlias("gemini-3-pro-low", "agy/gemini-3.1-pro-low"); + await modelsDb.setModelAlias("gemini-3-pro-preview", "agy/gemini-pro-agent"); + await modelsDb.setModelAlias("gemini-3.1-pro-preview", "agy/gemini-pro-agent"); + + const result = await seedDefaultModelAliases(); + const aliases = await modelsDb.getModelAliases(); + + assert.deepEqual(result.removed.sort(), [ + "gemini-3-pro-high", + "gemini-3-pro-low", + "gemini-3-pro-preview", + "gemini-3.1-pro-preview", + ]); + assert.ok(result.applied.includes("gemini-3.1-pro")); + assert.equal(aliases["gemini-3.1-pro"], "agy/gemini-pro-agent"); + assert.equal(aliases["gemini-3-pro-high"], undefined); + assert.equal(aliases["gemini-3-pro-low"], undefined); + assert.equal(aliases["gemini-3-pro-preview"], undefined); + assert.equal(aliases["gemini-3.1-pro-preview"], undefined); +}); + +test("default model alias seed removes the short-lived Pro High alias", async () => { + await modelsDb.setModelAlias("gemini-3-pro-high", "agy/gemini-pro-agent"); + + const result = await seedDefaultModelAliases(); + const aliases = await modelsDb.getModelAliases(); + + assert.ok(result.removed.includes("gemini-3-pro-high")); + assert.equal(aliases["gemini-3-pro-high"], undefined); + assert.equal(aliases["gemini-3.1-pro"], "agy/gemini-pro-agent"); +}); + +test("default model alias seed removes the retired Gemini Flash default alias", async () => { + await modelsDb.setModelAlias("gemini-3-flash-preview", "agy/gemini-3.5-flash-medium"); + + const result = await seedDefaultModelAliases(); + const aliases = await modelsDb.getModelAliases(); + + assert.deepEqual(result.removed, ["gemini-3-flash-preview"]); + assert.equal(aliases["gemini-3-flash-preview"], undefined); +}); + +test("default model alias seed preserves a customized retired alias", async () => { + await modelsDb.setModelAlias("gemini-3-flash-preview", "custom/provider-model"); + + const result = await seedDefaultModelAliases(); + const aliases = await modelsDb.getModelAliases(); + + assert.deepEqual(result.removed, []); + assert.equal(aliases["gemini-3-flash-preview"], "custom/provider-model"); +}); diff --git a/tests/unit/model-capabilities-registry.test.ts b/tests/unit/model-capabilities-registry.test.ts index c4be2f0e49..8a4725eae7 100644 --- a/tests/unit/model-capabilities-registry.test.ts +++ b/tests/unit/model-capabilities-registry.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { MODEL_SPECS } from "../../src/shared/constants/modelSpecs.ts"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-model-capabilities-")); process.env.DATA_DIR = TEST_DATA_DIR; @@ -68,9 +69,8 @@ test("canonical model capability resolver lets exact synced metadata override gl }), }, antigravity: { - // Since #3229, ANTIGRAVITY_MODEL_ALIASES maps both "gemini-3.1-pro-high" and - // "gemini-3.1-pro-low" → "gemini-3.1-pro", so the capability resolver looks - // synced metadata up under the canonical "gemini-3.1-pro" key. Save it there. + // gemini-pro-agent is the callable High id and resolves to the shared + // Gemini 3.1 Pro capability family. "gemini-3.1-pro": buildCapability({ tool_call: false, reasoning: false, @@ -92,19 +92,14 @@ test("canonical model capability resolver lets exact synced metadata override gl assert.equal(modelCapabilities.getModelContextLimit("openai", "gpt-4o-2024-11-20"), 256000); assert.equal(modelCapabilities.capMaxOutputTokens("openai/gpt-4o-2024-11-20", 999999), 12345); - const geminiHigh = modelCapabilities.getResolvedModelCapabilities( - "antigravity/gemini-3.1-pro-high" - ); + const geminiHigh = modelCapabilities.getResolvedModelCapabilities("antigravity/gemini-pro-agent"); assert.equal(geminiHigh.toolCalling, false); assert.equal(geminiHigh.reasoning, false); assert.equal(geminiHigh.supportsThinking, false); assert.equal(geminiHigh.contextWindow, 1024); assert.equal(geminiHigh.maxOutputTokens, 9999); assert.equal(geminiHigh.defaultThinkingBudget, 24576); - assert.equal( - modelCapabilities.capThinkingBudget("antigravity/gemini-3.1-pro-high", 40000), - 32768 - ); + assert.equal(modelCapabilities.capThinkingBudget("antigravity/gemini-pro-agent", 40000), 32768); const codexGpt55 = modelCapabilities.getResolvedModelCapabilities("codex/gpt-5.5"); assert.equal(codexGpt55.contextWindow, 400000); @@ -159,6 +154,40 @@ test("unknown models keep maxOutputTokens null instead of using a generic defaul ); }); +test("Antigravity Gemini 3.5 upstream IDs share the Flash capability profile", () => { + for (const modelId of [ + "gemini-3.5-flash-extra-low", + "gemini-3.5-flash-low", + "gemini-3-flash-agent", + ]) { + const spec = MODEL_SPECS[modelId]; + assert.ok(spec, `missing exact MODEL_SPECS entry for ${modelId}`); + const capabilities = modelCapabilities.getResolvedModelCapabilities(`antigravity/${modelId}`); + assert.equal(capabilities.contextWindow, 1048576, modelId); + assert.equal(capabilities.maxOutputTokens, 65536, modelId); + assert.equal(capabilities.supportsThinking, false, modelId); + assert.equal(capabilities.supportsTools, true, modelId); + assert.equal(capabilities.supportsVision, true, modelId); + } +}); + +test("Antigravity Gemini 3.6 tier IDs share the Flash capability profile", () => { + for (const modelId of [ + "gemini-3.6-flash-high", + "gemini-3.6-flash-medium", + "gemini-3.6-flash-low", + ]) { + const spec = MODEL_SPECS[modelId]; + assert.ok(spec, `missing exact MODEL_SPECS entry for ${modelId}`); + const capabilities = modelCapabilities.getResolvedModelCapabilities(`antigravity/${modelId}`); + assert.equal(capabilities.contextWindow, 1048576, modelId); + assert.equal(capabilities.maxOutputTokens, 65536, modelId); + assert.equal(capabilities.supportsThinking, false, modelId); + assert.equal(capabilities.supportsTools, true, modelId); + assert.equal(capabilities.supportsVision, true, modelId); + } +}); + test("GPT OSS and DeepSeek Reasoner models support tool calling", () => { // GPT OSS models should not be blocked by the heuristic assert.equal(modelCapabilities.supportsToolCalling("fake-provider/gpt-oss-120b"), true); diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index 1a51272b69..ed76e9a59e 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -679,32 +679,35 @@ test("v1 models catalog exposes bare Codex-preferred IDs for native Codex client assert.equal(providerModel.parent, aliasModel.id); }); -test("v1 models catalog exposes Antigravity client-visible preview aliases instead of upstream internal IDs", async () => { +test("v1 models catalog exposes current Antigravity aliases without retired model IDs", async () => { await seedConnection("antigravity", { authType: "oauth", name: "antigravity-preview", apiKey: null, accessToken: "antigravity-access", }); - const response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); const body = (await response.json()) as any; const ids = new Set(body.data.map((item) => item.id)); - assert.equal(response.status, 200); - assert.ok(ids.has("antigravity/gemini-3-pro-preview")); + assert.equal(ids.has("antigravity/gemini-3-pro-preview"), false); + assert.equal(ids.has("antigravity/gemini-3.1-pro"), false); + assert.equal(ids.has("antigravity/gemini-2.5-computer-use-preview-10-2025"), false); + assert.equal(ids.has("antigravity/rev19-uic3-1p"), false); + assert.ok(ids.has("antigravity/gemini-3.6-flash-high")); + assert.ok(ids.has("antigravity/gemini-3.6-flash-medium")); + assert.ok(ids.has("antigravity/gemini-3.6-flash-low")); + assert.ok(ids.has("antigravity/gemini-3.5-flash-extra-low")); assert.ok(ids.has("antigravity/gemini-3.5-flash-low")); - assert.ok(ids.has("antigravity/gemini-3.5-flash-medium")); - assert.ok(ids.has("antigravity/gemini-3.5-flash-high")); + assert.ok(ids.has("antigravity/gemini-3-flash-agent")); + assert.equal(ids.has("antigravity/gemini-3.5-flash-medium"), false); + assert.equal(ids.has("antigravity/gemini-3.5-flash-high"), false); + assert.equal(ids.has("antigravity/gemini-3.5-flash-preview"), false); assert.equal(ids.has("antigravity/gemini-3-flash-preview"), false); - assert.equal(ids.has("antigravity/gemini-3-flash-agent"), false); - // Gemini 3.1 Pro budget tiers remain client-visible aliases for the plain - // `gemini-3.1-pro` upstream id — see ANTIGRAVITY_MODEL_ALIASES. - assert.ok(ids.has("antigravity/gemini-3.1-pro-high")); - // The legacy `gemini-claude-*` ids are alias KEYS (remapped to live upstream - // ids), not public catalog entries, so they stay unexposed. + assert.equal(ids.has("antigravity/gemini-3.1-pro-high"), false); + assert.ok(ids.has("antigravity/gemini-pro-agent")); assert.equal(ids.has("antigravity/gemini-claude-sonnet-4-5"), false); assert.equal(ids.has("antigravity/gemini-claude-sonnet-4-5-thinking"), false); assert.equal(ids.has("antigravity/gemini-claude-opus-4-5-thinking"), false); diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index a2a868f046..49432b2ab7 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -551,8 +551,11 @@ test("Antigravity runs mocked browser OAuth exchanges and post-exchange enrichme (_url, init: any = {}) => { assert.equal(init.method, "POST"); assert.equal(init.headers.Authorization, "Bearer anti-access"); - assert.match(init.headers["User-Agent"], /^vscode\/1\.X\.X \(Antigravity\//); - assert.equal(init.headers["X-Goog-Api-Client"], undefined); + assert.match( + init.headers["User-Agent"], + /^antigravity\/2\.1\.1 [^ ]+\/[^ ]+ google-api-nodejs-client\/10\.3\.0$/ + ); + assert.equal(init.headers["X-Goog-Api-Client"], "gl-node/22.21.1"); assert.deepEqual( JSON.parse(String(init.body)).metadata, getAntigravityLoadCodeAssistMetadata() @@ -566,8 +569,11 @@ test("Antigravity runs mocked browser OAuth exchanges and post-exchange enrichme (_url, init: any = {}) => { assert.equal(init.method, "POST"); assert.equal(init.headers.Authorization, "Bearer anti-access"); - assert.match(init.headers["User-Agent"], /^vscode\/1\.X\.X \(Antigravity\//); - assert.equal(init.headers["X-Goog-Api-Client"], undefined); + assert.match( + init.headers["User-Agent"], + /^antigravity\/2\.1\.1 [^ ]+\/[^ ]+ google-api-nodejs-client\/10\.3\.0$/ + ); + assert.equal(init.headers["X-Goog-Api-Client"], "gl-node/22.21.1"); assert.deepEqual( JSON.parse(String(init.body)).metadata, getAntigravityLoadCodeAssistMetadata() @@ -599,6 +605,7 @@ test("Antigravity runs mocked browser OAuth exchanges and post-exchange enrichme // no longer updates the returned projectId synchronously — matching the 9router web // flow, which also returns the loadCodeAssist project id. assert.equal(antigravityMapped.projectId, "anti-project"); + assert.equal(antigravityMapped.providerSpecificData.clientProfile, "ide"); }); test("Qoder enabled mode exchanges tokens and loads profile metadata through mocked endpoints", async () => { diff --git a/tests/unit/pricing-ag-flash-tiers.test.ts b/tests/unit/pricing-ag-flash-tiers.test.ts index d28dc79ddb..28b5cb66d8 100644 --- a/tests/unit/pricing-ag-flash-tiers.test.ts +++ b/tests/unit/pricing-ag-flash-tiers.test.ts @@ -5,32 +5,29 @@ import { getDefaultPricing } from "../../src/shared/constants/pricing.ts"; // Antigravity exposes Gemini 3.5 Flash via three public client IDs in // ANTIGRAVITY_PUBLIC_MODELS (`open-sse/config/antigravityModelAliases.ts`): +// - gemini-3.5-flash-extra-low → "Gemini 3.5 Flash (Low)" — upstream Low tier // - gemini-3-flash-agent → "Gemini 3.5 Flash (High)" — upstream High tier // - gemini-3.5-flash-low → "Gemini 3.5 Flash (Medium)" — upstream Medium tier // - gemini-pro-agent → "Gemini 3.1 Pro (High)" — upstream Pro High alias // All three were missing pricing rows in `ag` (DEFAULT_PRICING.ag), so // getPricingForModel("ag", id) returned null and downstream cost / quota -// calculations silently fell back to $0. The same pricing schedule used for -// the legacy `gemini-3-flash` and `gemini-3.1-pro-high` rows applies (same -// per-MTok rates as the upstream quota tier they map to). +// calculations silently fell back to $0. Each row matches its upstream quota tier. -test("ag/gemini-3-flash-agent matches the Gemini 3.5 Flash (High) tier", () => { - const p = getDefaultPricing().ag["gemini-3-flash-agent"]; - assert.equal(p.input, 0.5); - assert.equal(p.output, 3.0); - assert.equal(p.cached, 0.03); - assert.equal(p.reasoning, 4.5); - assert.equal(p.cache_creation, 0.5); -}); - -test("ag/gemini-3.5-flash-low matches the Gemini 3.5 Flash (Medium) tier", () => { - const p = getDefaultPricing().ag["gemini-3.5-flash-low"]; - assert.equal(p.input, 0.5); - assert.equal(p.output, 3.0); - assert.equal(p.cached, 0.03); - assert.equal(p.reasoning, 4.5); - assert.equal(p.cache_creation, 0.5); -}); +for (const [modelId, tier] of [ + ["gemini-3.5-flash-extra-low", "Low"], + ["gemini-3.5-flash-low", "Medium"], + ["gemini-3-flash-agent", "High"], +] as const) { + test(`ag/${modelId} matches the Gemini 3.5 Flash (${tier}) tier`, () => { + const p = getDefaultPricing().ag[modelId]; + assert.ok(p); + assert.equal(p.input, 0.5); + assert.equal(p.output, 3.0); + assert.equal(p.cached, 0.03); + assert.equal(p.reasoning, 4.5); + assert.equal(p.cache_creation, 0.5); + }); +} test("ag/gemini-pro-agent matches the Gemini 3.1 Pro (High) tier", () => { const p = getDefaultPricing().ag["gemini-pro-agent"]; diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index f82f4abd8f..b03879bccc 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -24,7 +24,7 @@ async function resetStorage() { } else { process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS = originalAllowPrivateProviderUrls; } - antigravityVersion.clearAntigravityVersionCache(); + antigravityVersion.clearAntigravityVersionCaches(); core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); @@ -927,7 +927,7 @@ test("provider models route retries Antigravity discovery endpoints before retur apiKey: null, }); const seenUrls: string[] = []; - antigravityVersion.seedAntigravityVersionCache("1.22.2"); + antigravityVersion.seedAntigravityIdeVersionCache("1.22.2"); globalThis.fetch = async (url, init = {}) => { const urlString = String(url); @@ -944,13 +944,24 @@ test("provider models route retries Antigravity discovery endpoints before retur assert.equal(init.method, "POST"); assert.equal(init.headers.Authorization, "Bearer ag-access"); - assert.match(init.headers["User-Agent"], /^Antigravity\/1\.22\.2 /); + assert.match(init.headers["User-Agent"], /^antigravity\/ide\/1\.22\.2 /); assert.equal(init.headers["x-goog-api-client"], undefined); // Use a model id that is in the current user-callable Antigravity allowlist, otherwise // filterUserCallableAntigravityModels() drops it and discovery silently yields 0 models // → the route falls back to local_catalog instead of returning the remote (api) list. return Response.json({ - models: [{ id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash" }], + models: [ + { id: "gemini-3.1-pro-high", displayName: "Gemini 3.1 Pro (High)" }, + { id: "gemini-pro-agent", displayName: "Gemini 3.1 Pro (High)" }, + { id: "gemini-3.6-flash-high", displayName: "upstream-3.6-high" }, + { id: "gemini-3.6-flash-medium", displayName: "upstream-3.6-medium" }, + { id: "gemini-3.6-flash-low", displayName: "upstream-3.6-low" }, + { id: "gemini-3.5-flash-extra-low", displayName: "upstream-low" }, + { id: "gemini-3.5-flash-low", displayName: "upstream-medium" }, + { id: "gemini-3-flash-agent", displayName: "upstream-high" }, + { id: "gemini-3.5-flash-high", displayName: "retired-friendly-high" }, + { id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash" }, + ], }); }; @@ -971,7 +982,16 @@ test("provider models route retries Antigravity discovery endpoints before retur "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", ]); - assert.deepEqual(body.models, [{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }]); + assert.deepEqual(body.models, [ + { id: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" }, + { 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.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)" }, + { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" }, + { id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" }, + { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, + ]); }); test("provider models route falls back through all Antigravity discovery endpoints when needed", async () => { @@ -999,7 +1019,19 @@ test("provider models route falls back through all Antigravity discovery endpoin "https://cloudcode-pa.googleapis.com/v1internal:models", "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:models", ]); - assert.ok(body.models.some((model) => model.id === "gemini-3-pro-preview")); + assert.equal( + body.models.some((model) => model.id === "gemini-3.1-pro-high"), + false + ); + assert.ok(body.models.some((model) => model.id === "gemini-pro-agent")); + assert.equal( + body.models.some((model) => model.id === "gemini-3-pro-preview"), + false + ); + assert.equal( + body.models.some((model) => model.id === "gemini-2.5-computer-use-preview-10-2025"), + false + ); }); test("provider models route filters hidden models from the static Claude catalog when requested", async () => { diff --git a/tests/unit/provider-request-capture-toolname-antigravity-4181.test.ts b/tests/unit/provider-request-capture-toolname-antigravity-4181.test.ts index 948a7ff2fc..02f7decc70 100644 --- a/tests/unit/provider-request-capture-toolname-antigravity-4181.test.ts +++ b/tests/unit/provider-request-capture-toolname-antigravity-4181.test.ts @@ -1,41 +1,15 @@ -/** - * #4181 (follow-up to #4091) — Antigravity tool-name cloak map must survive the - * request-inspector capture round-trip. - * - * The #4091 fix in `createPreparedRequestLogger().body()` re-attaches the - * non-enumerable `_toolNameMap` that the fetch-capture drops when it rebuilds the - * upstream body via `JSON.parse(JSON.stringify(...))`. That fix is generic, but - * #4153 only regression-tested the native-Claude OAuth cloak - * (`claudeCodeToolRemapper.ts`, PascalCase aliases). - * - * Antigravity cloaks differently: `cloakAntigravityToolPayload` - * (`open-sse/config/toolCloaking.ts`) suffixes custom tools with `_ide` - * (`workspace_read` → `workspace_read_ide`), leaves native tools (`run_command`, - * …) untouched, and returns the reverse map SEPARATELY — the executor then pins it - * onto the transformed body as a non-enumerable `_toolNameMap` via - * `attachToolNameMap` (`open-sse/executors/antigravity.ts`). These tests pin that - * the same lossy boundary is covered for the Antigravity suffix scheme, so a - * future refactor of `providerRequestLogging.ts` or the executor can't silently - * re-break Antigravity tool dispatch. - */ import test from "node:test"; import assert from "node:assert/strict"; +import { sanitizeAntigravityToolPayload } from "../../open-sse/config/toolCloaking.ts"; import { createPreparedRequestLogger, type ProviderRequestPrepared, } from "../../open-sse/utils/providerRequestLogging.ts"; -import { - cloakAntigravityToolPayload, - AG_TOOL_SUFFIX, -} from "../../open-sse/config/toolCloaking.ts"; function makeCapture() { - const logged: unknown[] = []; const reqLogger = { - logTargetRequest: (_url: unknown, _headers: Record, body: unknown) => { - logged.push(body); - }, + logTargetRequest: (_url: unknown, _headers: Record, _body: unknown) => {}, }; const scope = { id: null, @@ -43,146 +17,57 @@ function makeCapture() { provider: "antigravity", connectionId: null, }; - return { capture: createPreparedRequestLogger(reqLogger, scope), logged }; + return createPreparedRequestLogger(reqLogger, scope); } -const CUSTOM_TOOL = "workspace_read"; -const CLOAKED_TOOL = `workspace_read${AG_TOOL_SUFFIX}`; // workspace_read_ide -const NATIVE_TOOL = "run_command"; // AG_DEFAULT — must stay untouched - -/** - * Mirror the executor's `attachToolNameMap` (antigravity.ts) exactly: pin the - * reverse map as a NON-ENUMERABLE `_toolNameMap` so it never re-serializes into - * the upstream request — this is the body the downstream capture+`body()` sees. - */ -function attachToolNameMap(payload: T, map: Map): T { - const copy = { ...payload } as T; - Object.defineProperty(copy, "_toolNameMap", { - value: map, - enumerable: false, - configurable: true, - writable: true, - }); - return copy; -} - -function makeCloakedAntigravityBody(): Record { - const body: Record = { +test("Antigravity request capture preserves original tool names without a cloak map", () => { + const capture = makeCapture(); + const source: Record = { model: "gemini-2.5-pro", request: { tools: [ { functionDeclarations: [ - { name: CUSTOM_TOOL, description: "Read a file", parameters: { type: "OBJECT", properties: {} } }, - { name: NATIVE_TOOL, description: "Run a shell command", parameters: { type: "OBJECT", properties: {} } }, + { + name: "workspace_read", + description: "Read a file", + parameters: { type: "OBJECT", properties: {} }, + }, ], }, ], - contents: [], + contents: [ + { + role: "model", + parts: [{ functionCall: { name: "workspace_read", args: { path: "/tmp/a" } } }], + }, + ], }, }; - - const cloaked = cloakAntigravityToolPayload(body); - const map = cloaked.toolNameMap; - - // Sanity: the Antigravity cloak fired with its `_ide` scheme and the custom - // tool was renamed while the native tool was left alone. - assert.ok(map instanceof Map, "cloak must produce a reverse tool-name map"); - assert.equal(map.get(CLOAKED_TOOL), CUSTOM_TOOL, "reverse map: _ide alias → original"); - assert.equal(map.get(NATIVE_TOOL), undefined, "native tools are not cloaked"); - - const declarations = ( - (cloaked.body.request as Record).tools as Array<{ - functionDeclarations: Array<{ name: string }>; - }> - )[0].functionDeclarations.map((d) => d.name); - assert.ok(declarations.includes(CLOAKED_TOOL), "custom tool declaration carries the _ide suffix"); - assert.ok(declarations.includes(NATIVE_TOOL), "native tool declaration is preserved verbatim"); - - return attachToolNameMap(cloaked.body, map); -} - -test("#4181 body() preserves the Antigravity _ide cloak map dropped by the capture round-trip", () => { - const { capture } = makeCapture(); - const transformedBody = makeCloakedAntigravityBody(); - - // The fetch-capture serializes the outgoing body and rebuilds it — exactly what - // `captureFetchRequest` does — which strips the non-enumerable map. - const bodyString = JSON.stringify(transformedBody); - const captured = JSON.parse(bodyString); - assert.equal( - (captured as Record)._toolNameMap, - undefined, - "precondition: JSON round-trip drops the non-enumerable _toolNameMap" - ); + const sanitized = sanitizeAntigravityToolPayload(source); + const bodyString = JSON.stringify(sanitized); const prepared: ProviderRequestPrepared = { - url: "https://server.codeium.com/exa.language_server_pb.LanguageServerService/GenerateAntigravity", + url: "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", headers: {}, - body: captured, + body: JSON.parse(bodyString), bodyString, }; capture.capture(prepared); - // finalBody is what chatCore feeds to the response-side un-cloak. - const finalBody = capture.body(transformedBody) as Record; - const map = finalBody._toolNameMap; - assert.ok(map instanceof Map, "finalBody must still carry the per-request Antigravity cloak map"); - assert.equal( - (map as Map).get(CLOAKED_TOOL), - CUSTOM_TOOL, - "_ide alias must resolve back to the original tool name" - ); -}); - -test("#4181 the re-attached Antigravity map stays non-enumerable (never re-serializes upstream)", () => { - const { capture } = makeCapture(); - const transformedBody = makeCloakedAntigravityBody(); - const bodyString = JSON.stringify(transformedBody); - capture.capture({ - url: "https://server.codeium.com/...", - headers: {}, - body: JSON.parse(bodyString), - bodyString, - }); - - const finalBody = capture.body(transformedBody) as Record; - assert.ok(finalBody._toolNameMap instanceof Map); - assert.ok( - !Object.keys(finalBody).includes("_toolNameMap"), - "_toolNameMap must stay non-enumerable so it never re-serializes upstream" - ); - assert.ok( - !JSON.stringify(finalBody).includes("_toolNameMap"), - "_toolNameMap must not appear in a serialized provider body" - ); -}); - -test("#4181 body() leaves non-cloaked Antigravity traffic untouched (no spurious map)", () => { - const { capture } = makeCapture(); - // Only native tools → cloak produces no map, executor attaches nothing. - const plainBody: Record = { - model: "gemini-2.5-pro", + const finalBody = capture.body(sanitized) as { + _toolNameMap?: unknown; request: { - tools: [ - { - functionDeclarations: [ - { name: NATIVE_TOOL, description: "Run a shell command", parameters: { type: "OBJECT", properties: {} } }, - ], - }, - ], - contents: [], - }, + tools: Array<{ functionDeclarations: Array<{ name: string }> }>; + contents: Array<{ parts: Array<{ functionCall: { name: string } }> }>; + }; }; - const cloaked = cloakAntigravityToolPayload(plainBody); - assert.equal(cloaked.toolNameMap, null, "all-native payload yields no reverse map"); - const bodyString = JSON.stringify(cloaked.body); - capture.capture({ - url: "https://server.codeium.com/...", - headers: {}, - body: JSON.parse(bodyString), - bodyString, - }); - const finalBody = capture.body(cloaked.body) as Record; + assert.equal("_toolNameMap" in sanitized, false); assert.equal(finalBody._toolNameMap, undefined); + assert.deepEqual( + finalBody.request.tools[0].functionDeclarations.map((declaration) => declaration.name), + ["workspace_read"] + ); + assert.equal(finalBody.request.contents[0].parts[0].functionCall.name, "workspace_read"); + assert.equal(bodyString.includes("_ide"), false); }); diff --git a/tests/unit/provider-translate-path-golden.test.ts b/tests/unit/provider-translate-path-golden.test.ts index 1ae7832428..7cffac3d66 100644 --- a/tests/unit/provider-translate-path-golden.test.ts +++ b/tests/unit/provider-translate-path-golden.test.ts @@ -78,6 +78,11 @@ function sanitize(headers: Record): Record { /Macintosh; Intel Mac OS X 10_15_7|Windows NT 10\.0; Win64; x64|X11; Linux x86_64/g, "" ) + .replace(/(antigravity\/ide\/\d+\.\d+\.\d+) [^/\s]+\/[^\s)]+/g, "$1 /") + .replace( + /(antigravity\/cli\/\d+\.\d+\.\d+ \(aidev_client; os_type=)[^;]+(; arch=)[^;]+(; auth_method=[^)]+\))/g, + "$1$2$3" + ) .replace(/kimi-\d{10,}/g, "kimi-") .replace(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, ""); if (NODE_VERSION) s = s.split(NODE_VERSION).join(""); diff --git a/tests/unit/sse-auth-antigravity-credits.test.ts b/tests/unit/sse-auth-antigravity-credits.test.ts new file mode 100644 index 0000000000..6b038fb4b3 --- /dev/null +++ b/tests/unit/sse-auth-antigravity-credits.test.ts @@ -0,0 +1,81 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-sse-auth-ag-credits-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "sse-auth-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const auth = await import("../../src/sse/services/auth.ts"); +const quotaCache = await import("../../src/domain/quotaCache.ts"); + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("Antigravity always mode bypasses request-path quota preflight", async () => { + const previousCreditsMode = process.env.ANTIGRAVITY_CREDITS; + process.env.ANTIGRAVITY_CREDITS = "always"; + + try { + const conn = await providersDb.createProviderConnection({ + provider: "antigravity", + name: "antigravity-credits-first", + authType: "oauth", + accessToken: "fake-antigravity-access", + refreshToken: "fake-antigravity-refresh", + isActive: true, + testStatus: "active", + providerSpecificData: { + quotaPreflightEnabled: true, + limitPolicy: { + enabled: true, + thresholdPercent: 75, + windows: ["daily"], + }, + }, + }); + quotaCache.setQuotaCache(conn.id, "antigravity", { + daily: { remainingPercentage: 10, resetAt: new Date(Date.now() + 60_000).toISOString() }, + }); + + const quotaPreflight = await import("../../open-sse/services/quotaPreflight.ts"); + let fetcherCalls = 0; + quotaPreflight.registerQuotaFetcher("antigravity", async () => { + fetcherCalls++; + return { used: 0, total: 100, percentUsed: 0 }; + }); + + const selected = await auth.getProviderCredentialsWithQuotaPreflight( + "antigravity", + null, + null, + "gemini-2.5-flash" + ); + + assert(selected && "connectionId" in selected); + assert.equal(selected.connectionId, conn.id); + assert.equal(fetcherCalls, 0, "credits-first routing must not issue a normal quota probe"); + } finally { + if (previousCreditsMode === undefined) delete process.env.ANTIGRAVITY_CREDITS; + else process.env.ANTIGRAVITY_CREDITS = previousCreditsMode; + } +}); diff --git a/tests/unit/t28-model-catalog-updates.test.ts b/tests/unit/t28-model-catalog-updates.test.ts index 848749e144..6c9229920b 100644 --- a/tests/unit/t28-model-catalog-updates.test.ts +++ b/tests/unit/t28-model-catalog-updates.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { getModelInfoCore } from "../../open-sse/services/model.ts"; import { REGISTRY } from "../../open-sse/config/providerRegistry.ts"; +import { FREE_MODEL_BUDGETS } from "../../open-sse/config/freeModelCatalog.data.ts"; import { getStaticModelsForProvider } from "../../src/lib/providers/staticModels.ts"; test("T28: gemini AI Studio catalog includes current preview models", () => { @@ -19,24 +20,47 @@ test("T28: gemini AI Studio catalog includes current preview models", () => { assert.equal(geminiIds[0], "gemini-3.1-pro-preview", "preserve the existing Gemini default"); }); -test("T28: antigravity static catalog exposes client-visible Gemini tier IDs", () => { +test("T28: antigravity static catalog exposes only callable Gemini tier IDs", () => { const staticIds = (getStaticModelsForProvider("antigravity") || []).map((m) => m.id); - assert.ok(staticIds.includes("gemini-3-pro-preview")); + assert.ok(!staticIds.includes("gemini-3-pro-preview")); + assert.ok(staticIds.includes("gemini-3.6-flash-high")); + assert.ok(staticIds.includes("gemini-3.6-flash-medium")); + assert.ok(staticIds.includes("gemini-3.6-flash-low")); + assert.ok(staticIds.includes("gemini-3.5-flash-extra-low")); assert.ok(staticIds.includes("gemini-3.5-flash-low")); - assert.ok(staticIds.includes("gemini-3.5-flash-medium")); - assert.ok(staticIds.includes("gemini-3.5-flash-high")); + assert.ok(staticIds.includes("gemini-3-flash-agent")); + assert.ok(!staticIds.includes("gemini-3.5-flash-medium")); + assert.ok(!staticIds.includes("gemini-3.5-flash-high")); assert.ok(staticIds.includes("gemini-3.1-pro-low")); - assert.ok(staticIds.includes("gemini-3.1-pro-high")); + assert.ok(!staticIds.includes("gemini-3.1-pro-high")); + assert.ok(staticIds.includes("gemini-pro-agent")); // Legacy aliases that were never client-visible stay absent. assert.ok(!staticIds.includes("gemini-3-pro-high")); assert.ok(!staticIds.includes("gemini-3-flash-preview")); - assert.ok(!staticIds.includes("gemini-3-flash-agent")); assert.ok(!staticIds.includes("gemini-claude-sonnet-4-5")); assert.ok(!staticIds.includes("gemini-claude-sonnet-4-5-thinking")); assert.ok(!staticIds.includes("gemini-claude-opus-4-5-thinking")); }); +test("T28: agy free-model metadata labels upstream Gemini 3.6 tier IDs", () => { + const flashNames = Object.fromEntries( + FREE_MODEL_BUDGETS.filter( + (entry) => + entry.provider === "agy" && + ["gemini-3.6-flash-low", "gemini-3.6-flash-medium", "gemini-3.6-flash-high"].includes( + entry.modelId + ) + ).map((entry) => [entry.modelId, entry.displayName]) + ); + + assert.deepEqual(flashNames, { + "gemini-3.6-flash-low": "Gemini 3.6 Flash (Low)", + "gemini-3.6-flash-medium": "Gemini 3.6 Flash (Medium)", + "gemini-3.6-flash-high": "Gemini 3.6 Flash (High)", + }); +}); + test("T28: github registry exposes Gemini 3.1 Pro Preview and keeps legacy alias compatibility", async () => { const githubIds = REGISTRY.github.models.map((m) => m.id); diff --git a/tests/unit/translator-openai-to-gemini-defaults.test.ts b/tests/unit/translator-openai-to-gemini-defaults.test.ts new file mode 100644 index 0000000000..4faab9c487 --- /dev/null +++ b/tests/unit/translator-openai-to-gemini-defaults.test.ts @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiToCloudCodeGeminiRequest } = + await import("../../open-sse/translator/request/openai-to-gemini.ts"); + +test("OpenAI -> Cloud Code Gemini applies native request defaults", () => { + // gemini-3.1-pro is thinking-capable; the previous fixture (gemini-3-flash-preview, + // supportsThinking: false / cap 0) encoded the pre-#6943 bug of requesting thoughts + // from a non-thinking model — reasoning_effort on a capped-at-0 model now correctly + // yields thinkingBudget 0 / includeThoughts false (see the flash assertion below). + const request = openaiToCloudCodeGeminiRequest( + "gemini-3.1-pro", + { + messages: [{ role: "user", content: "Hello" }], + reasoning_effort: "high", + }, + true + ) as { + model: string; + generationConfig: { thinkingConfig: { includeThoughts: boolean }; topK?: number }; + contents: Array<{ parts: Array<{ text: string }> }>; + }; + + assert.equal(request.model, "gemini-3.1-pro"); + assert.equal(request.generationConfig.thinkingConfig.includeThoughts, true); + + const flash = openaiToCloudCodeGeminiRequest( + "gemini-3-flash-preview", + { messages: [{ role: "user", content: "Hello" }], reasoning_effort: "high" }, + true + ) as { + generationConfig: { thinkingConfig: { thinkingBudget: number; includeThoughts: boolean } }; + }; + assert.equal(flash.generationConfig.thinkingConfig.thinkingBudget, 0); + assert.equal(flash.generationConfig.thinkingConfig.includeThoughts, false); + assert.equal(request.generationConfig.topK, undefined); + assert.equal(request.contents.at(-1).parts[0].text, "Hello"); +}); diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index 9be3c9e9b0..1dc70bad6e 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -517,34 +517,6 @@ test("OpenAI -> Gemini helper IDs and JSON parsing stay in the expected format", assert.equal(tryParseJSON("not-json"), null as any); }); -test("OpenAI -> Cloud Code Gemini applies native request defaults", () => { - // gemini-3.1-pro is thinking-capable; the previous fixture (gemini-3-flash-preview, - // supportsThinking: false / cap 0) encoded the pre-#6943 bug of requesting thoughts - // from a non-thinking model — reasoning_effort on a capped-at-0 model now correctly - // yields thinkingBudget 0 / includeThoughts false (see the flash assertion below). - const request = openaiToCloudCodeGeminiRequest( - "gemini-3.1-pro", - { - messages: [{ role: "user", content: "Hello" }], - reasoning_effort: "high", - }, - true - ) as any; - - assert.equal(request.model, "gemini-3.1-pro"); - assert.equal(request.generationConfig.thinkingConfig.includeThoughts, true); - - const flash = openaiToCloudCodeGeminiRequest( - "gemini-3-flash-preview", - { messages: [{ role: "user", content: "Hello" }], reasoning_effort: "high" }, - true - ) as { generationConfig: { thinkingConfig: { thinkingBudget: number; includeThoughts: boolean } } }; - assert.equal(flash.generationConfig.thinkingConfig.thinkingBudget, 0); - assert.equal(flash.generationConfig.thinkingConfig.includeThoughts, false); - assert.equal(request.generationConfig.topK, undefined); - assert.equal(request.contents.at(-1).parts[0].text, "Hello"); -}); - test("OpenAI -> Cloud Code Gemini emits native functionResponse result", () => { const request = openaiToCloudCodeGeminiRequest( "gemini-3-flash-preview", @@ -610,13 +582,12 @@ test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", () "model", "userAgent", "requestType", - "enabledCreditTypes", ]); assert.equal(result.userAgent, "antigravity"); assert.equal(result.requestType, "agent"); assert.match(result.requestId, /^agent\/\d+\/[0-9a-f]{8}$/); assert.match(result.request.sessionId, /^-?\d+$/); - assert.deepEqual(result.enabledCreditTypes, ["GOOGLE_ONE_AI"]); + assert.equal(result.enabledCreditTypes, undefined); assert.equal(result.request.generationConfig.topK, 40); assert.equal(result.request.generationConfig.topP, 1.0); assert.equal( @@ -887,7 +858,7 @@ test("OpenAI -> Antigravity maps Claude-family models to Gemini-compatible schem assert.equal(result.project, "proj-claude"); assert.equal(result.userAgent, "antigravity"); assert.match(result.requestId, /^agent\/\d+\/[0-9a-f]{8}$/); - assert.deepEqual((result as any).enabledCreditTypes, ["GOOGLE_ONE_AI"]); + assert.equal(result.enabledCreditTypes, undefined); 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, undefined); diff --git a/tests/unit/usage-service-antigravity-credits.test.ts b/tests/unit/usage-service-antigravity-credits.test.ts new file mode 100644 index 0000000000..1de15b9284 --- /dev/null +++ b/tests/unit/usage-service-antigravity-credits.test.ts @@ -0,0 +1,104 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const usageService = await import("../../open-sse/services/usage.ts"); +const originalFetch = globalThis.fetch; +const originalCreditsMode = process.env.ANTIGRAVITY_CREDITS; + +test.afterEach(() => { + globalThis.fetch = originalFetch; + if (originalCreditsMode === undefined) delete process.env.ANTIGRAVITY_CREDITS; + else process.env.ANTIGRAVITY_CREDITS = originalCreditsMode; +}); + +test("usage service scheduled Antigravity refresh does not proactively spend credits", async () => { + process.env.ANTIGRAVITY_CREDITS = "always"; + let probeCalls = 0; + let modelCalls = 0; + let loadCodeAssistCalls = 0; + + globalThis.fetch = async (url) => { + const urlStr = String(url); + if (urlStr.includes("loadCodeAssist")) { + loadCodeAssistCalls++; + return new Response(JSON.stringify({ cloudaicompanionProject: "ag-project" }), { + status: 200, + }); + } + if (urlStr.includes("streamGenerateContent")) { + probeCalls++; + return new Response("data: [DONE]\n\n", { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + if (urlStr.includes("fetchAvailableModels")) { + modelCalls++; + return new Response(JSON.stringify({ models: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`unexpected fetch: ${url}`); + }; + + await usageService.getUsageForProvider( + { + id: "ag-scheduled-refresh-service-test", + provider: "antigravity", + accessToken: "ag-scheduled-service-token", + projectId: "ag-project", + }, + { forceRefresh: false } + ); + + assert.equal(probeCalls, 0); + assert.equal(modelCalls, 1); + assert.equal(loadCodeAssistCalls, 1); +}); + +test("usage service manual refresh does not proactively spend credits in retry mode", async () => { + process.env.ANTIGRAVITY_CREDITS = "retry"; + let probeCalls = 0; + let modelCalls = 0; + let loadCodeAssistCalls = 0; + + globalThis.fetch = async (url) => { + const urlStr = String(url); + if (urlStr.includes("loadCodeAssist")) { + loadCodeAssistCalls++; + return new Response(JSON.stringify({ cloudaicompanionProject: "ag-retry-project" }), { + status: 200, + }); + } + if (urlStr.includes("streamGenerateContent")) { + probeCalls++; + return new Response("data: [DONE]\n\n", { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + if (urlStr.includes("fetchAvailableModels")) { + modelCalls++; + return new Response(JSON.stringify({ models: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`unexpected fetch: ${url}`); + }; + + const connection = { + id: "ag-retry-manual-refresh-service-test", + provider: "antigravity", + accessToken: "ag-retry-manual-service-token", + projectId: "ag-retry-project", + }; + + await usageService.getUsageForProvider(connection, { forceRefresh: true }); + await usageService.getUsageForProvider(connection, { forceRefresh: true }); + + assert.equal(probeCalls, 0); + assert.equal(modelCalls, 2); + assert.equal(loadCodeAssistCalls, 2); +}); diff --git a/tests/unit/usage-service-hardening.test.ts b/tests/unit/usage-service-hardening.test.ts index dd9a537dee..c2185d9e7a 100644 --- a/tests/unit/usage-service-hardening.test.ts +++ b/tests/unit/usage-service-hardening.test.ts @@ -5,7 +5,7 @@ const usageService = await import("../../open-sse/services/usage.ts"); const { __testing } = usageService; const { getAntigravityLoadCodeAssistMetadata } = await import("../../open-sse/services/antigravityHeaders.ts"); -const { getAntigravityFetchAvailableModelsUrls } = +const { ANTIGRAVITY_BOOTSTRAP_BASE_URLS, getAntigravityFetchAvailableModelsUrls } = await import("../../open-sse/config/antigravityUpstream.ts"); const originalFetch = globalThis.fetch; @@ -215,8 +215,11 @@ test("usage service covers Antigravity quota parsing, exclusions and forbidden a assert.equal(usage.quotas["gemini-pro-agent"].remainingPercentage, 100); assert.equal(usage.quotas["claude-sonnet-4-6"].remainingPercentage, 40); const loadCodeAssistCall = calls.find((call) => call.url.includes("loadCodeAssist")); - assert.match(loadCodeAssistCall?.url, /daily-cloudcode-pa\.sandbox\.googleapis\.com/); - assert.match(loadCodeAssistCall?.init.headers["User-Agent"], /^vscode\/1\.X\.X \(Antigravity\//); + assert.equal( + loadCodeAssistCall?.url, + `${ANTIGRAVITY_BOOTSTRAP_BASE_URLS[0]}/v1internal:loadCodeAssist` + ); + assert.match(loadCodeAssistCall?.init.headers["User-Agent"], /^antigravity\/ide\/2\.1\.1 /); assert.equal(loadCodeAssistCall?.init.headers["X-Goog-Api-Client"], undefined); assert.equal(loadCodeAssistCall?.init.headers["Client-Metadata"], undefined); assert.deepEqual( @@ -256,7 +259,7 @@ test("usage service prefers Antigravity retrieveUserQuota over catalog quotaInfo return new Response( JSON.stringify({ models: { - "gemini-3.5-flash-high": { + "gemini-3-flash-agent": { quotaInfo: { remainingFraction: 1, resetTime: new Date(Date.now() + 60_000).toISOString(), @@ -273,7 +276,7 @@ test("usage service prefers Antigravity retrieveUserQuota over catalog quotaInfo JSON.stringify({ buckets: [ { - modelId: "gemini-3.5-flash-high", + modelId: "gemini-3-flash-agent", remainingFraction: 0.25, resetTime: new Date(Date.now() + 60_000).toISOString(), }, @@ -291,12 +294,12 @@ test("usage service prefers Antigravity retrieveUserQuota over catalog quotaInfo accessToken: `ag-token-live-quota-${Date.now()}`, }); - assert.equal(usage.quotas["gemini-3.5-flash-high"].remainingPercentage, 25); - assert.equal(usage.quotas["gemini-3.5-flash-high"].used, 750); - assert.equal(usage.quotas["gemini-3.5-flash-high"].quotaSource, "retrieveUserQuota"); + assert.equal(usage.quotas["gemini-3-flash-agent"].remainingPercentage, 25); + assert.equal(usage.quotas["gemini-3-flash-agent"].used, 750); + assert.equal(usage.quotas["gemini-3-flash-agent"].quotaSource, "retrieveUserQuota"); }); -test("usage service normalizes retired Antigravity quota bucket ids", async () => { +test("usage service preserves Antigravity upstream quota bucket ids", async () => { globalThis.fetch = async (url) => { const urlString = String(url); @@ -344,11 +347,11 @@ test("usage service normalizes retired Antigravity quota bucket ids", async () = accessToken: `ag-token-legacy-buckets-${Date.now()}`, }); - assert.equal(usage.quotas["gemini-3-flash-agent"], undefined); - assert.equal(usage.quotas["gemini-3.5-flash-extra-low"], undefined); - assert.equal(usage.quotas["gemini-3.5-flash-high"].remainingPercentage, 50); - assert.equal(usage.quotas["gemini-3.5-flash-medium"].remainingPercentage, 100); - assert.equal(usage.quotas["gemini-3.5-flash-low"].remainingPercentage, 25); + assert.equal(usage.quotas["gemini-3-flash-agent"].remainingPercentage, 50); + assert.equal(usage.quotas["gemini-3.5-flash-extra-low"].remainingPercentage, 25); + assert.equal(usage.quotas["gemini-3.5-flash-low"].remainingPercentage, 100); + assert.equal(usage.quotas["gemini-3.5-flash-medium"], undefined); + assert.equal(usage.quotas["gemini-3.5-flash-high"], undefined); }); test("usage service retries Antigravity fetchAvailableModels across the shared fallback order", async () => { @@ -395,18 +398,18 @@ test("usage service retries Antigravity fetchAvailableModels across the shared f }); const quotaCalls = calls.filter((call) => call.url.includes("fetchAvailableModels")); - // ANTIGRAVITY_BASE_URLS order changed: daily first, then cloudcode-pa, then sandbox last + // Discovery fallback order is daily production, production Cloud Code, then sandbox. assert.deepEqual( quotaCalls.map((call) => call.url), expectedQuotaUrls ); - assert.match(quotaCalls[2].init.headers["User-Agent"], /^Antigravity\//); + assert.match(quotaCalls.at(-1)?.init.headers["User-Agent"], /^antigravity\/ide\//); assert.equal(usage.plan, "Business"); assert.ok(usage.quotas["gemini-pro-agent"] !== undefined); }); test("usage service manual Antigravity refresh bypasses usage TTL caches", async () => { - process.env.ANTIGRAVITY_CREDITS = "retry"; + process.env.ANTIGRAVITY_CREDITS = "always"; let probeCalls = 0; let modelCalls = 0; let loadCodeAssistCalls = 0;