From cfdfb85de199fc29a947f19d75dae248a2e74578 Mon Sep 17 00:00:00 2001 From: Xinwei Gao Date: Fri, 7 Aug 2026 13:04:21 +0800 Subject: [PATCH 1/6] fix(google): discover Antigravity live models --- docs-site/src/content/docs/contributing.md | 3 +- .../src/content/docs/guides/providers.md | 2 +- .../src/content/docs/ja/guides/providers.md | 2 +- .../src/content/docs/ko/guides/providers.md | 2 +- .../src/content/docs/ru/guides/providers.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- src/codex/catalog/provider-fetch.ts | 67 ++++++++-- src/codex/convergence-types.ts | 2 +- src/oauth/index.ts | 68 ++++------ src/providers/antigravity-models.ts | 88 ++++++++++++- src/providers/registry.ts | 4 +- src/server/management/provider-routes.ts | 51 +++++--- src/server/responses/core.ts | 6 +- tests/antigravity-static-catalog.test.ts | 1 + tests/codex-catalog.test.ts | 3 +- tests/google-models-listing.test.ts | 117 +++++++++++++++++- tests/oauth-provider-reconcile.test.ts | 26 ++-- tests/provider-connection-test.test.ts | 39 +++++- tests/provider-registry-parity.test.ts | 6 +- 19 files changed, 383 insertions(+), 108 deletions(-) diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index f8954b90e9..bf8017e5ba 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -164,7 +164,8 @@ sent to. A preset therefore needs primary-source evidence, not a working code pa that add or promote a provider must supply all of the following in the description: - **The documented OpenAI-compatible endpoints.** Link the vendor's own API reference for the chat - endpoint and, when the entry sets `liveModels: true`, for authenticated `GET /v1/models`. A + endpoint and, when the entry sets `liveModels: true`, for its authenticated model-discovery + endpoint (typically `GET /v1/models`). A passing fixture test is not a substitute: it proves our code shape, not the upstream contract. - **Terms of service and the operating legal entity.** An empty or placeholder legal page does not establish who runs the endpoint or under what terms user traffic is handled. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index af1d0ef4ab..07d0677e1e 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -113,7 +113,7 @@ ocx logout | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude models; live model list fetched from `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install | bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1' | iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Uses the maintained six-model static catalog because CCA does not expose the generic `/models` endpoint. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index a5d15561b1..da7e633170 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -108,7 +108,7 @@ ocx logout | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude モデル; ライブモデル一覧は `/v1/models` から取得。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 コーディングモデル。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初回ログインは、インストール済みでサインインした `kiro-cli` セッションを取り込みます(Unix では `curl -fsSL https://cli.kiro.dev/install | bash`、Windows PowerShell では `irm 'https://cli.kiro.dev/install.ps1' | iex` でインストールしてから `kiro-cli login` を実行)。**アカウントを追加**は `kiro-cli` をログアウトして新しいブラウザログインを開始し、`kiro-cli` 自体のアカウントを切り替えてアカウント別プロファイルメタデータを保存します。既存の OpenCodex アカウントは保持され、キャンセルまたは失敗時には以前の `kiro-cli` セッションが復元されます。 | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。CCA は汎用 `/models` エンドポイントを公開しないため、管理された 6 モデルの静的カタログを使用します。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。ライブ探索は認証済みの CCA `v1internal:fetchAvailableModels` エンドポイントを使用し、ログイン中のアカウントで利用可能な agent モデルのみを公開します。管理されたカタログはフォールバックとして残ります。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 実験的。GitHub デバイスフロー + `copilot_internal` 交換(VS Code OAuth クライアント)。有効な Copilot サブスクリプションが必要で、公式のサードパーティ API ではありません。 | diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 1c74f02cf9..de93be7cf6 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -107,7 +107,7 @@ ocx logout | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 모델; 실시간 모델 목록은 `/v1/models`에서 가져옵니다. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 코딩 모델. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 최초 로그인은 설치하고 로그인한 `kiro-cli` 세션을 가져옵니다(Unix에서는 `curl -fsSL https://cli.kiro.dev/install | bash`, Windows PowerShell에서는 `irm 'https://cli.kiro.dev/install.ps1' | iex`로 설치한 뒤 `kiro-cli login` 실행). **계정 추가**는 `kiro-cli`에서 로그아웃한 뒤 새 브라우저 로그인을 시작하여 `kiro-cli` 자체의 계정을 전환하고, 계정별 프로필 메타데이터를 저장합니다. 기존 OpenCodex 계정은 유지되며, 취소되거나 실패하면 이전 `kiro-cli` 세션을 복원합니다. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. CCA가 범용 `/models` 엔드포인트를 제공하지 않으므로 유지 관리되는 6개 모델 정적 카탈로그를 사용합니다. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. 실시간 탐색은 인증된 CCA `v1internal:fetchAvailableModels` 엔드포인트를 사용하며 로그인한 계정에서 사용할 수 있는 agent 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. | | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 실험적. GitHub 디바이스 플로우 + `copilot_internal` 교환(VS Code OAuth 클라이언트). 활성 Copilot 구독 필요; 공식 서드파티 API가 아닙니다. | diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 977bff86b7..79125612cf 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -117,7 +117,7 @@ ocx logout | `anthropic` | `anthropic` | `https://api.anthropic.com` | Модели Claude; актуальный список моделей загружается из `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Модели Kimi K2.7/K2.6/K2.5 для кодинга. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Первый вход импортирует существующую сессию после установки Kiro CLI (в Unix: `curl -fsSL https://cli.kiro.dev/install | bash`; в Windows PowerShell: `irm 'https://cli.kiro.dev/install.ps1' | iex`; затем выполните `kiro-cli login`). **Добавить аккаунт** выполняет выход из `kiro-cli`, запускает новый вход через браузер, переключает аккаунт самого `kiro-cli` и сохраняет метаданные профиля отдельно для каждого аккаунта. Существующие аккаунты OpenCodex сохраняются; при отмене или сбое восстанавливается предыдущая сессия `kiro-cli`. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Используется поддерживаемый статический каталог из шести моделей, поскольку CCA не предоставляет общий эндпоинт `/models`. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Живое обнаружение использует аутентифицированный CCA-эндпоинт `v1internal:fetchAvailableModels` и публикует только agent-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 490fd696a4..e016f19468 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -98,7 +98,7 @@ ocx logout | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 模型;实时模型列表从 `/v1/models` 获取。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 编程模型。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(Unix 使用 `curl -fsSL https://cli.kiro.dev/install | bash`;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1' | iex`;然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。由于 CCA 不提供通用 `/models` 端点,因此使用维护中的六模型静态目录。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、HTTP/2 传输和按账号筛选的模型发现。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 79d3d55b90..8f18f11c8d 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -20,6 +20,7 @@ import { } from "../model-cache"; import { buildModelsRequest, + getValidAccessTokenSnapshot, observeActiveOAuthAccessToken, resolveModelsAuthToken, type OAuthActiveTokenObservation, @@ -29,7 +30,8 @@ import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; -import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry"; +import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry"; +import { parseAntigravityAvailableModels } from "../../providers/antigravity-models"; import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec"; import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; @@ -118,6 +120,7 @@ interface ModelsAuthResolution { readonly apiKey: string | undefined; readonly observed: boolean; readonly oauthApiBaseUrl?: string; + readonly oauthProjectId?: string; } type ModelsAuthResolver = @@ -132,7 +135,7 @@ type ModelsAuthResolverFactory = ( ) => ModelsAuthResolver; interface CapturedModelsRequest { - readonly method: "GET"; + readonly method: "GET" | "POST"; readonly url: string; readonly headersWithoutCredential: Readonly>; readonly headersWithCredential: Readonly>; @@ -359,11 +362,12 @@ function captureModelsRequest( : undefined; const withoutCredential = buildModelsRequest(provider, undefined, name, observed); const withCredential = buildModelsRequest(provider, REQUEST_CREDENTIAL_SENTINEL, name, observed); - if (withoutCredential.url !== withCredential.url) { + const method = withoutCredential.method ?? "GET"; + if (withoutCredential.url !== withCredential.url || method !== (withCredential.method ?? "GET")) { throw new TypeError(`Provider model discovery URL for ${name} depends on credential bytes.`); } return detachedFrozen({ - method: "GET" as const, + method, url: withoutCredential.url, headersWithoutCredential: withoutCredential.headers, headersWithCredential: withCredential.headers, @@ -830,6 +834,7 @@ function observedModelsAuthResolver( apiKey: observation.snapshot.accessToken, observed: true, ...(observation.snapshot.apiBaseUrl ? { oauthApiBaseUrl: observation.snapshot.apiBaseUrl } : {}), + ...(observation.snapshot.projectId ? { oauthProjectId: observation.snapshot.projectId } : {}), }; }, }; @@ -864,7 +869,15 @@ async function fetchProviderModelsWithAuth( return observed(configured, "authoritative"); } const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing" - ? { apiKey: await resolveModelsAuthToken(name, prov), observed: false } + ? effectiveGoogleMode(name, prov) === "cloud-code-assist" + ? await getValidAccessTokenSnapshot(name) + .then(snapshot => ({ + apiKey: snapshot.accessToken, + observed: false, + ...(snapshot.projectId ? { oauthProjectId: snapshot.projectId } : {}), + })) + .catch(() => ({ apiKey: undefined, observed: false })) + : { apiKey: await resolveModelsAuthToken(name, prov), observed: false } : resolveAuth.resolve(name, prov)); const apiKey = auth.apiKey; // A configured default is a real callable selector and must remain discoverable when a @@ -927,6 +940,7 @@ async function fetchProviderModelsWithAuth( // matching Cursor's !apiKey → configured degradation and fetch-failure fallback. return observed(configured, "degraded"); } + if (effectiveGoogleMode(name, prov) === "cloud-code-assist" && !auth.oauthProjectId) return configured; const fresh = getFreshCached(name, ttlMs); if (fresh) { return observed( @@ -969,10 +983,19 @@ async function fetchProviderModelsWithAuth( }; }; try { - const res = await providerOutboundGet(name, prov, url, { - headers, - signal: AbortSignal.timeout(8000), - }); + const providerFetch = (prov as OcxProviderConfig & { fetch?: typeof fetch }).fetch; + const res = request.method === "POST" + ? await (providerFetch ?? globalThis.fetch)(url, { + method: "POST", + headers, + body: JSON.stringify({ project: auth.oauthProjectId }), + redirect: "manual", + signal: AbortSignal.timeout(8000), + }) + : await providerOutboundGet(name, prov, url, { + headers, + signal: AbortSignal.timeout(8000), + }); const redirectError = await providerRedirectError(res, url); if (redirectError) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status }); @@ -1011,6 +1034,32 @@ async function fetchProviderModelsWithAuth( } return observed(models, "degraded"); } + const antigravity = effectiveGoogleMode(name, prov) === "cloud-code-assist" + ? parseAntigravityAvailableModels(bounded.value) + : undefined; + if (effectiveGoogleMode(name, prov) === "cloud-code-assist" && !antigravity) { + const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); + if (shouldLog) { + console.warn( + `[opencodex] Provider model discovery for "${name}" returned malformed CCA model data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, + ); + } + return models; + } + if (antigravity) { + const live = antigravity.map(model => applyProviderConfigHints(name, prov, { + id: model.id, + provider: name, + // CCA only exposes a numeric thinking budget. Until the adapter owns an exact Codex + // effort-to-wire mapping for a newly discovered model, do not advertise a false ladder. + reasoningEfforts: [], + ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}), + ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}), + }, contextCap)); + markProviderDiscoveryOk(name, live.length); + setCached(name, live); + return live; + } const extracted = extractProviderModelItems(bounded.value, discovery); if (!extracted.ok) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); diff --git a/src/codex/convergence-types.ts b/src/codex/convergence-types.ts index 06bd62e48e..515ee8c940 100644 --- a/src/codex/convergence-types.ts +++ b/src/codex/convergence-types.ts @@ -422,7 +422,7 @@ export interface CatalogProviderDiscoveryPolicySnapshot { readonly path: CatalogDiscoveryPolicyField; readonly query: CatalogDiscoveryPolicyField> | undefined>; }>; - readonly finalMethod: "GET"; + readonly finalMethod: "GET" | "POST"; readonly finalUrl: string; readonly filter: CatalogDiscoveryPolicyField; readonly maxResponseBytes: number; diff --git a/src/oauth/index.ts b/src/oauth/index.ts index a8b2911410..a537869d6e 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -13,6 +13,7 @@ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity" import { loginCursor, refreshCursorToken } from "./cursor"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; import { loginCommandCode, refreshCommandCodeToken } from "./command-code"; +import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive"; import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys"; import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../providers/registry"; @@ -53,6 +54,8 @@ export interface OAuthAccessSnapshot { accountId: string; generation: string; accessToken: string; + /** Cloud Code Assist project selected during Antigravity login. */ + projectId?: string; /** Safe request-routing subset; refresh-only Kiro client secrets never leave the credential store. */ kiro?: Pick; } @@ -289,6 +292,7 @@ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredenti accountId, generation: credentialGeneration(cred), accessToken: cred.access, + ...(cred.projectId ? { projectId: cred.projectId } : {}), // Stored account metadata remains authoritative. Metadata-less legacy/environment credentials // may use explicit environment routing, but never borrow the currently signed-in local CLI account. ...(provider === "kiro" @@ -628,15 +632,15 @@ function modelDiscoveryTransportSeed(providerName: string, prov: OcxProviderConf } /** - * Provider-correct `GET /models` request (URL + headers), so both model-listing paths fetch the + * Provider-correct model-discovery request (URL + headers), so both model-listing paths fetch the * LIVE catalog correctly per adapter. Anthropic is the special case: its endpoint is `/v1/models` * (not `/models`), it needs `anthropic-version`, and it authenticates with `x-api-key` by default * (or `Authorization: Bearer` when `apiKeyTransport = "bearer"`), plus the OAuth beta for oauth * mode — not a bare Bearer. Google (ai-studio mode) * is the other special case: `x-goog-api-key` + `/v1beta/models`, returning `{ models: [...] }`. * The catalog authority gate intentionally degrades that non-OpenAI shape to stale/static data. - * Everyone else uses the OpenAI-style `/models` + Bearer with a `{ data: [{ id, owned_by? }] }` - * response. + * Antigravity uses its CCA `:fetchAvailableModels` RPC; everyone else uses the OpenAI-style + * `/models` + Bearer with a `{ data: [{ id, owned_by? }] }` response. */ export interface ModelsRequestObservedAuth { readonly oauthApiBaseUrl?: string; @@ -647,7 +651,7 @@ export function buildModelsRequest( apiKey: string | undefined, providerName = "", observedAuth?: ModelsRequestObservedAuth, -): { url: string; headers: Record } { +): { method?: "POST"; url: string; headers: Record } { const transportSeed = modelDiscoveryTransportSeed(providerName, prov); const copilotApiBaseUrl = observedAuth === undefined ? (providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(providerName) : undefined) @@ -665,6 +669,17 @@ export function buildModelsRequest( effectiveProvider.baseUrl, defaultUrl, ); + if (effectiveGoogleMode(providerName, effectiveProvider) === "cloud-code-assist") { + headers.Accept = "application/json"; + headers["Content-Type"] = "application/json"; + headers["User-Agent"] = ANTIGRAVITY_REQUEST_UA; + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + return { + method: "POST", + url: discoveryUrl(`${effectiveProvider.baseUrl.replace(/\/+$/, "")}/v1internal:fetchAvailableModels`), + headers, + }; + } if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") { // Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer), // models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 — @@ -698,9 +713,7 @@ export function buildModelsRequest( * * Only touches providers that are registry-managed AND still `authMode: "oauth"`. Preset fields * are refreshed, while the registry's `liveModels` default is normally filled only when no value - * is stored. Antigravity has one versioned exception below because its old GUI-generated `true` - * cannot be distinguished from a hand-written pre-migration `true`. Persists + returns true when - * anything changed. + * is stored. Persists + returns true when anything changed. */ function cloneProviderField(value: unknown): unknown { if (Array.isArray(value)) return [...value]; @@ -728,9 +741,6 @@ const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [ "preserveReasoningContentModels", ]; -const GOOGLE_ANTIGRAVITY_PROVIDER = "google-antigravity"; -const GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION = 1 as const; - /** Only migrate the three-model experimental seed; an operator's later `liveModels: false` wins. */ function isLegacyCommandCodeStaticCatalog(provider: OcxProviderConfig): boolean { return provider.liveModels === false @@ -740,8 +750,6 @@ function isLegacyCommandCodeStaticCatalog(provider: OcxProviderConfig): boolean export function reconcileOAuthProviders(config: OcxConfig): boolean { let changed = false; - const migrateAntigravityStaticCatalog = - config.googleAntigravityStaticCatalogVersion !== GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION; for (const [name, prov] of Object.entries(config.providers)) { const def = OAUTH_PROVIDERS[name]; if (name === "command-code" && isLegacyCommandCodeStaticCatalog(prov)) { @@ -750,21 +758,7 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean { prov.liveModels = true; changed = true; } - // Normalize the canonical row before the OAuth-only reconciliation guard. The old GUI and a - // manual edit both persist the same bare `true`, with no source metadata, so every ambiguous - // pre-marker value is reset once. A deliberate live-discovery choice can be re-enabled after - // the marker and is then preserved. Do this before the guard so omitted/non-OAuth authMode - // rows do not get stamped without actually receiving the new static default. - if (name === GOOGLE_ANTIGRAVITY_PROVIDER && migrateAntigravityStaticCatalog && prov.liveModels !== false) { - prov.liveModels = false; - changed = true; - } - // During the one-time Antigravity static-catalog migration, also refresh preset catalog - // fields when authMode is omitted or non-oauth. Otherwise liveModels flips to static while - // a stale models[] remains the published catalog forever. - const migrateAntigravityCatalogFields = - name === GOOGLE_ANTIGRAVITY_PROVIDER && migrateAntigravityStaticCatalog; - if (!def || (prov.authMode !== "oauth" && !migrateAntigravityCatalogFields)) continue; + if (!def || prov.authMode !== "oauth") continue; const preset = def.providerConfig; for (const field of OAUTH_RECONCILE_FIELDS) { if (JSON.stringify(prov[field]) === JSON.stringify(preset[field])) continue; @@ -775,9 +769,6 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean { } changed = true; } - // Before this marker existed, the GUI materialized an omitted `liveModels` as `true` on any - // settings save. Since persisted values have no provenance, the pre-guard normalization above - // intentionally resets all pre-marker `true` values once. Later choices are version-bounded. if (prov.liveModels === undefined && preset.liveModels !== undefined) { prov.liveModels = preset.liveModels; changed = true; @@ -791,10 +782,6 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean { changed = true; } } - if (migrateAntigravityStaticCatalog) { - config.googleAntigravityStaticCatalogVersion = GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION; - changed = true; - } if (changed) saveConfig(config); return changed; } @@ -855,13 +842,9 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (namespaceCollision) throw new Error(namespaceCollision); const existing = config.providers[provider]; const next: OcxProviderConfig = { ...def.providerConfig }; - // `liveModels` is a user-facing provider toggle. A registry default seeds new rows, but an - // explicit post-migration choice must survive re-login and the latest-config upsert. Old GUI - // saves and manual edits left identical pre-marker `true` values, so that ambiguous state is - // reset once; users who deliberately forced discovery can re-enable it after migration. - const preserveExistingLiveModels = provider !== GOOGLE_ANTIGRAVITY_PROVIDER - || config.googleAntigravityStaticCatalogVersion === GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION; - if (preserveExistingLiveModels && typeof existing?.liveModels === "boolean" && !isLegacyCommandCodeStaticCatalog(existing)) { + // `liveModels` is a user-facing provider toggle. Preserve either explicit setting across login; + // Antigravity's CCA discovery now uses its real RPC, so legacy `true` remains a valid choice. + if (typeof existing?.liveModels === "boolean" && !isLegacyCommandCodeStaticCatalog(existing)) { next.liveModels = existing.liveModels; } // The Command Code protocol-version pin is an operator compatibility control. A re-login, @@ -893,9 +876,6 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { } } config.providers[provider] = next; - if (provider === GOOGLE_ANTIGRAVITY_PROVIDER) { - config.googleAntigravityStaticCatalogVersion = GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION; - } } interface RunLoginDeps { diff --git a/src/providers/antigravity-models.ts b/src/providers/antigravity-models.ts index 18b0b39612..8588d7e2c3 100644 --- a/src/providers/antigravity-models.ts +++ b/src/providers/antigravity-models.ts @@ -19,6 +19,14 @@ const ANTIGRAVITY_WIRE_MODELS = [ "gpt-oss-120b-medium", ]; +const ANTIGRAVITY_PICKER_MODEL_BY_WIRE_ID: Record = { + "gemini-3.6-flash-low": "gemini-3.6-flash", + "gemini-3.6-flash-medium": "gemini-3.6-flash", + "gemini-3.6-flash-high": "gemini-3.6-flash", + "gemini-3.1-pro-low": "gemini-3.1-pro", + "gemini-pro-agent": "gemini-3.1-pro", +}; + // ── Effort ladders per collapsed base model ── // Gemini models: effort → wire model suffix (official agy UI pattern). // Claude Opus: effort → thinkingConfig.thinkingLevel (CLIProxyAPI proven pattern). @@ -100,8 +108,8 @@ const ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS: Record = { "gemini-3.1-pro-low": 1_048_576, "gemini-pro-agent": 1_048_576, "gemini-3.1-flash-image": 1_048_576, - "claude-sonnet-4-6": 200_000, - "claude-opus-4-6-thinking": 1_000_000, + "claude-sonnet-4-6": 250_000, + "claude-opus-4-6-thinking": 250_000, "gpt-oss-120b-medium": 131_072, }; @@ -119,6 +127,82 @@ export const ANTIGRAVITY_MODEL_CONTEXT_WINDOWS: Record = { ), }; +export const ANTIGRAVITY_MODEL_INPUT_MODALITIES: Record = { + "gemini-3.6-flash": ["text", "image"], + "gemini-3.1-pro": ["text", "image"], + "gemini-3.1-flash-image": ["text", "image"], + "claude-sonnet-4-6": ["text", "image"], + "claude-opus-4-6-thinking": ["text", "image"], + "gpt-oss-120b-medium": ["text"], +}; + +export interface AntigravityAvailableModel { + id: string; + contextWindow?: number; + inputModalities?: string[]; +} + +function antigravityRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function antigravityPositiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +/** + * Extract the CCA models that are valid for agent requests. The endpoint also returns tab, + * command, commit-message, transcription, and standalone image-generation models; those are not + * callable through the CCA agent envelope and must not be published to the Codex catalog. + */ +export function parseAntigravityAvailableModels(payload: unknown): AntigravityAvailableModel[] | null { + const body = antigravityRecord(payload); + if (!body) return null; + const models = antigravityRecord(body?.models); + const sorts = Array.isArray(body?.agentModelSorts) ? body.agentModelSorts : undefined; + if (!models || !sorts) return null; + + const ids: string[] = []; + for (const sort of sorts) { + const groups = antigravityRecord(sort)?.groups; + if (!Array.isArray(groups)) continue; + for (const group of groups) { + const modelIds = antigravityRecord(group)?.modelIds; + if (!Array.isArray(modelIds)) continue; + for (const id of modelIds) { + if (typeof id === "string" && id) ids.push(id); + } + } + } + // This model is exposed by Antigravity's agent chat surface even though it is grouped under + // image generation in the discovery response. + if (Array.isArray(body.imageGenerationModelIds) + && body.imageGenerationModelIds.includes("gemini-3.1-flash-image")) { + ids.push("gemini-3.1-flash-image"); + } + + const out: AntigravityAvailableModel[] = []; + const seen = new Set(); + for (const wireId of ids) { + const info = antigravityRecord(models[wireId]); + if (!info) continue; + const id = ANTIGRAVITY_PICKER_MODEL_BY_WIRE_ID[wireId] ?? wireId; + // Legacy compatibility aliases are deliberately routed to newer wire ids for saved + // selections. They are not safe as independently discovered picker rows. + if (ANTIGRAVITY_MODEL_ALIASES[wireId] && ANTIGRAVITY_MODEL_ALIASES[wireId] !== wireId) continue; + if (seen.has(id)) continue; + seen.add(id); + out.push({ + id, + ...(antigravityPositiveInteger(info.maxTokens) ? { contextWindow: antigravityPositiveInteger(info.maxTokens) } : {}), + inputModalities: info.supportsImages === true ? ["text", "image"] : ["text"], + }); + } + return out; +} + export function resolveAntigravityWireModelId(modelId: string): string { return ANTIGRAVITY_MODEL_ALIASES[modelId] ?? modelId; } diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 6568162538..8da2f91f87 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1,6 +1,6 @@ import type { CodexAccountMode, OcxProviderConfig } from "../types"; import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "./kiro-models"; -import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS } from "./antigravity-models"; +import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS, ANTIGRAVITY_MODEL_INPUT_MODALITIES } from "./antigravity-models"; import type { ProviderBaseUrlChoice } from "./base-url-choices"; import { QWEN_CLOUD_BASE_URL_CHOICES, QWEN_CLOUD_TOKEN_PLAN_BASE_URL, @@ -1287,7 +1287,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API // evidence from ai.google.dev does not establish Vertex publisher availability. { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] }, - { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: false, defaultModel: "gemini-3.6-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] }, + { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.6-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] }, { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index a196deabc9..3d462fff77 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -26,9 +26,10 @@ import { removeCredential } from "../../oauth/store"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { ProviderOutboundPolicyError, providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound"; +import { parseAntigravityAvailableModels } from "../../providers/antigravity-models"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets } from "../../providers/derive"; -import { providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry"; +import { effectiveGoogleMode, providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry"; import { extractModelEnvelopeRows, extractProviderModelItems, @@ -506,19 +507,35 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise undefined) + : undefined; + const apiKey = snapshot?.accessToken ?? await resolveModelsAuthToken(name, prov); if (prov.authMode === "oauth" && !apiKey) { return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" }); } - const { url: modelsUrl, headers } = buildModelsRequest(prov, apiKey, name); + if (antigravity && !snapshot?.projectId) { + return jsonResponse({ ok: false, latencyMs: 0, error: "Antigravity project unavailable — re-run `ocx login google-antigravity`" }); + } + const { method, url: modelsUrl, headers } = buildModelsRequest(prov, apiKey, name); const discovery = resolveProviderModelDiscovery(name, prov); const started = Date.now(); try { - const res = await providerOutboundGet(name, prov, modelsUrl, { - headers, - signal: AbortSignal.timeout(8000), - }); + const providerFetch = (prov as OcxProviderConfig & { fetch?: typeof fetch }).fetch; + const res = method === "POST" + ? await (providerFetch ?? globalThis.fetch)(modelsUrl, { + method, + headers, + body: JSON.stringify({ project: snapshot!.projectId }), + redirect: "manual", + signal: AbortSignal.timeout(8000), + }) + : await providerOutboundGet(name, prov, modelsUrl, { + headers, + signal: AbortSignal.timeout(8000), + }); const latencyMs = Date.now() - started; const redirectError = await providerRedirectError(res, modelsUrl); if (redirectError) { @@ -534,7 +551,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise : undefined; - const extracted = Array.isArray(bounded.value) || Array.isArray(record?.data) + const extracted = ccaModels + ? undefined + : Array.isArray(bounded.value) || Array.isArray(record?.data) ? extractProviderModelItems(bounded.value, discovery) : extractModelEnvelopeRows(bounded.value, discovery.maxModels, ["models"]); - if (!extracted.ok) { + if (extracted && !extracted.ok) { return jsonResponse({ ok: false, latencyMs, @@ -564,7 +587,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { ...providerConfigSeed(entry), authMode: "key", apiKey: "test-token", + liveModels: false, }, }, })); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index a468354634..61c860af8c 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1988,9 +1988,10 @@ describe("Codex catalog routed normalization", () => { } }); - test("Google Antigravity uses its static registry catalog and suppresses stale discovery (#723)", async () => { + test("Google Antigravity honors an explicit static catalog and suppresses stale discovery", async () => { const providerName = "google-antigravity"; const provider = structuredClone(OAUTH_PROVIDERS[providerName].providerConfig); + provider.liveModels = false; const config = { port: 10100, defaultProvider: providerName, diff --git a/tests/google-models-listing.test.ts b/tests/google-models-listing.test.ts index 0a26fc3c70..7c3aa8588e 100644 --- a/tests/google-models-listing.test.ts +++ b/tests/google-models-listing.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { gatherRoutedModels as gatherRoutedModelsDirect } from "../src/codex/catalog"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildCatalogEntries, gatherRoutedModels as gatherRoutedModelsDirect } from "../src/codex/catalog"; import { buildModelsRequest } from "../src/oauth"; import { clearModelCache, getStaleCached } from "../src/codex/model-cache"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; @@ -10,10 +13,13 @@ const gatherRoutedModels: typeof gatherRoutedModelsDirect = (config, options) => gatherRoutedModelsDirect(withStubbedProviderFetch(config), options); const originalFetch = globalThis.fetch; +const originalOpencodexHome = process.env.OPENCODEX_HOME; afterEach(() => { globalThis.fetch = originalFetch; clearModelCache(); + if (originalOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalOpencodexHome; }); function configWith(name: string, prov: Partial): OcxConfig { @@ -38,13 +44,15 @@ describe("buildModelsRequest google routing", () => { expect(headers["x-goog-api-key"]).toBe("gk-123"); }); - test("an explicit Antigravity live-discovery override keeps Authorization: Bearer", () => { - // Static discovery is the preset default. If a user explicitly opts into the generic probe, - // a saved config may still omit googleMode — the registry's cloud-code-assist mode must win. + test("Antigravity uses its authenticated CCA model-discovery RPC", () => { + // A saved config may omit googleMode — the registry's cloud-code-assist mode must win. const prov = { adapter: "google", authMode: "oauth", baseUrl: "https://daily-cloudcode-pa.googleapis.com", liveModels: true } as OcxProviderConfig; - const { url, headers } = buildModelsRequest(prov, "oauth-token", "google-antigravity"); - expect(url).toBe("https://daily-cloudcode-pa.googleapis.com/models"); + const { method, url, headers } = buildModelsRequest(prov, "oauth-token", "google-antigravity"); + expect(method).toBe("POST"); + expect(url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"); expect(headers["Authorization"]).toBe("Bearer oauth-token"); + expect(headers["Content-Type"]).toBe("application/json"); + expect(headers.Accept).toBe("application/json"); expect(headers["x-goog-api-key"]).toBeUndefined(); }); @@ -55,6 +63,103 @@ describe("buildModelsRequest google routing", () => { }); }); +describe("Antigravity live model discovery", () => { + test("uses the CCA agent list and applies CCA metadata", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-antigravity-discovery-")); + process.env.OPENCODEX_HOME = home; + writeFileSync(join(home, "auth.json"), JSON.stringify({ + "google-antigravity": { + activeAccountId: "active", + accounts: [{ + id: "active", + credential: { + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 3_600_000, + projectId: "project-id", + }, + }], + }, + })); + const seen: { url: string; init?: RequestInit }[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + seen.push({ url: String(input), init }); + return Response.json({ + models: { + "gemini-3.6-flash-low": { maxTokens: 1_048_576, supportsImages: true, supportsThinking: true, thinkingBudget: 1000 }, + "gemini-3.6-flash-high": { maxTokens: 1_048_576, supportsImages: true, supportsThinking: true, thinkingBudget: 10000 }, + "future-agent-model": { maxTokens: 333_333, supportsImages: false, supportsThinking: true, thinkingBudget: 7777 }, + "gemini-3.1-flash-image": { maxTokens: 555_555, supportsImages: true }, + "non-agent-command-model": { maxTokens: 222_222 }, + "tab-only-model": { maxTokens: 32_768 }, + }, + agentModelSorts: [{ groups: [{ modelIds: [ + "future-agent-model", "gemini-3.6-flash-low", "gemini-3.6-flash-high", + ] }] }], + imageGenerationModelIds: ["gemini-3.1-flash-image"], + tabModelIds: ["tab-only-model"], + commandModelIds: ["non-agent-command-model"], + }); + }) as typeof fetch; + + try { + const models = await gatherRoutedModels(configWith("google-antigravity", { + adapter: "google", + authMode: "oauth", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + liveModels: true, + models: ["configured-only"], + })); + const live = models.filter(model => model.provider === "google-antigravity"); + + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"); + expect(seen[0]?.init?.method).toBe("POST"); + expect((seen[0]?.init?.headers as Record).Authorization).toBe("Bearer access-token"); + expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({ project: "project-id" }); + expect(live.map(model => model.id).sort()).toEqual([ + "future-agent-model", + "gemini-3.1-flash-image", + "gemini-3.6-flash", + ]); + expect(live.find(model => model.id === "gemini-3.6-flash")).toMatchObject({ + contextWindow: 1_048_576, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high"], + }); + expect(live.find(model => model.id === "future-agent-model")).toMatchObject({ + contextWindow: 333_333, + inputModalities: ["text"], + reasoningEfforts: [], + }); + expect(live.map(model => model.id)).not.toContain("tab-only-model"); + expect(live.map(model => model.id)).not.toContain("non-agent-command-model"); + + const catalog = buildCatalogEntries(null, [], live); + const flash = catalog.find(entry => entry.slug === "google-antigravity/gemini-3.6-flash"); + const future = catalog.find(entry => entry.slug === "google-antigravity/future-agent-model"); + expect(flash).toMatchObject({ + context_window: 1_048_576, + max_context_window: 1_048_576, + auto_compact_token_limit: 943_718, + input_modalities: ["text", "image"], + default_reasoning_level: "medium", + }); + expect((flash?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) + .toEqual(["low", "medium", "high", "max", "ultra"]); + expect(future).toMatchObject({ + context_window: 333_333, + max_context_window: 333_333, + auto_compact_token_limit: 299_999, + input_modalities: ["text"], + }); + expect(future).not.toHaveProperty("default_reasoning_level"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); + describe("buildModelsRequest anthropic routing", () => { test("normalizes a /v1 baseUrl and keeps the Anthropic models path singular", () => { const prov = { diff --git a/tests/oauth-provider-reconcile.test.ts b/tests/oauth-provider-reconcile.test.ts index 1ee0830656..6b71643628 100644 --- a/tests/oauth-provider-reconcile.test.ts +++ b/tests/oauth-provider-reconcile.test.ts @@ -17,7 +17,7 @@ afterEach(() => { }); describe("OAuth provider reconciliation", () => { - test("migrates a saved Antigravity 3.5 preset without touching credentials or user fields", async () => { + test("refreshes a saved Antigravity 3.5 preset without touching credentials or user fields", async () => { const home = mkdtempSync(join(tmpdir(), "ocx-gemini-36-reconcile-")); homes.push(home); process.env.OPENCODEX_HOME = home; @@ -41,8 +41,6 @@ describe("OAuth provider reconciliation", () => { modelContextWindows: { "gemini-3.5-flash-low": 1_048_576 }, project: "config-project-sentinel", note: "user-owned-note", - // This is deliberately ambiguous: old Provider Settings saves and manual edits - // persisted the same value, so the versioned migration normalizes both once. liveModels: true, }, }, @@ -64,8 +62,7 @@ describe("OAuth provider reconciliation", () => { expect(provider.models).not.toContain("gemini-3.6-flash-medium"); expect(provider.models).not.toContain("gemini-3.6-flash-high"); expect(provider.modelContextWindows?.["gemini-3.6-flash"]).toBe(1_048_576); - expect(provider.liveModels).toBe(false); - expect(config.googleAntigravityStaticCatalogVersion).toBe(1); + expect(provider.liveModels).toBe(true); expect(provider.project).toBe("config-project-sentinel"); expect(provider.note).toBe("user-owned-note"); expect(getCredential("google-antigravity")).toMatchObject({ @@ -76,8 +73,7 @@ describe("OAuth provider reconciliation", () => { const persisted = loadConfig(); expect(persisted.providers["google-antigravity"]?.defaultModel).toBe("gemini-3.6-flash"); - expect(persisted.providers["google-antigravity"]?.liveModels).toBe(false); - expect(persisted.googleAntigravityStaticCatalogVersion).toBe(1); + expect(persisted.providers["google-antigravity"]?.liveModels).toBe(true); expect(reconcileOAuthProviders(config)).toBe(false); }); @@ -102,7 +98,7 @@ describe("OAuth provider reconciliation", () => { expect(config.providers["google-antigravity"].models).toHaveLength(6); }); - test("normalizes ambiguous pre-marker Antigravity rows even when authMode is omitted or non-OAuth", () => { + test("preserves explicit Antigravity live discovery when authMode is omitted or non-OAuth", () => { const home = mkdtempSync(join(tmpdir(), "ocx-antigravity-authmode-reconcile-")); homes.push(home); process.env.OPENCODEX_HOME = home; @@ -123,17 +119,16 @@ describe("OAuth provider reconciliation", () => { providers: { "google-antigravity": provider }, } satisfies OcxConfig; - expect(reconcileOAuthProviders(config)).toBe(true); - expect(config.googleAntigravityStaticCatalogVersion).toBe(1); + expect(reconcileOAuthProviders(config)).toBe(false); const migrated = config.providers["google-antigravity"]; - expect(migrated.liveModels).toBe(false); - expect(migrated.defaultModel).toBe(preset.defaultModel); - expect(migrated.models).toEqual(preset.models); + expect(migrated.liveModels).toBe(true); + expect(migrated.defaultModel).toBe("gemini-3.5-flash-low"); + expect(migrated.models).toEqual(["gemini-3.5-flash-low", "gemini-3.5-flash-high"]); expect(migrated.authMode).toBe(authMode); } }); - test("normalizes ambiguous pre-marker true during re-login, then preserves later overrides", () => { + test("preserves Antigravity live discovery during re-login", () => { const config = { port: 10100, defaultProvider: "google-antigravity", @@ -146,8 +141,7 @@ describe("OAuth provider reconciliation", () => { } satisfies OcxConfig; upsertOAuthProvider(config, "google-antigravity"); - expect(config.googleAntigravityStaticCatalogVersion).toBe(1); - expect(config.providers["google-antigravity"].liveModels).toBe(false); + expect(config.providers["google-antigravity"].liveModels).toBe(true); config.providers["google-antigravity"].liveModels = true; config.providers["google-antigravity"].authMode = "key"; diff --git a/tests/provider-connection-test.test.ts b/tests/provider-connection-test.test.ts index 2ada6a8b96..db9ec5ae68 100644 --- a/tests/provider-connection-test.test.ts +++ b/tests/provider-connection-test.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { handleManagementAPI } from "../src/server/management-api"; import { saveConfig } from "../src/config"; import { OAUTH_PROVIDERS } from "../src/oauth"; +import { saveCredential } from "../src/oauth/store"; import { PROVIDER_REGISTRY } from "../src/providers/registry"; import type { OcxConfig } from "../src/types"; import { withRegistryDiscovery } from "./helpers/provider-registry-discovery"; @@ -104,7 +105,10 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { throw new Error("static Antigravity catalog must not probe upstream"); }) as typeof fetch; const config = baseConfig({ - "google-antigravity": structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig), + "google-antigravity": { + ...structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig), + liveModels: false, + }, }); const { body } = await probe(config, "google-antigravity"); @@ -113,6 +117,39 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { expect(fetches).toBe(0); }); + test("Google Antigravity probes its CCA agent-model RPC", async () => { + const seen: { url: string; init?: RequestInit }[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + seen.push({ url: String(input), init }); + return Response.json({ + models: { + "any-agent-model": { maxTokens: 123_456 }, + "not-an-agent-model": { maxTokens: 65_536 }, + }, + agentModelSorts: [{ groups: [{ modelIds: ["any-agent-model"] }] }], + tabModelIds: ["not-an-agent-model"], + }); + }) as typeof fetch; + await saveCredential("google-antigravity", { + access: "test-access-token", + refresh: "test-refresh-token", + expires: Date.now() + 3_600_000, + projectId: "test-project-id", + }); + const config = baseConfig({ + "google-antigravity": structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig), + }); + + const { body } = await probe(config, "google-antigravity"); + + expect(body).toMatchObject({ ok: true, models: 1 }); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"); + expect(seen[0]?.init?.method).toBe("POST"); + expect((seen[0]?.init?.headers as Record).Authorization).toBe("Bearer test-access-token"); + expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({ project: "test-project-id" }); + }); + test("a fake key gets the upstream rejection, not a catalog-presence pass", async () => { globalThis.fetch = (async () => new Response("unauthorized", { status: 401 })) as typeof fetch; const config = baseConfig({ diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 0b24e63a4c..5f205e8a30 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -640,9 +640,9 @@ describe("provider registry parity", () => { expect(OAUTH_PROVIDERS.xai.providerConfig.modelReasoningEfforts?.["grok-4.5"]).toEqual(["low", "medium", "high"]); expect(OAUTH_PROVIDERS.xai.providerConfig.noVisionModels).toContain("grok-build-0.1"); const antigravityRegistry = PROVIDER_REGISTRY.find(entry => entry.id === "google-antigravity"); - expect(antigravityRegistry?.liveModels).toBe(false); - expect(providerConfigSeed(antigravityRegistry!).liveModels).toBe(false); - expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.liveModels).toBe(false); + expect(antigravityRegistry?.liveModels).toBe(true); + expect(providerConfigSeed(antigravityRegistry!).liveModels).toBe(true); + expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.liveModels).toBe(true); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.defaultModel).toBe("gemini-3.6-flash"); // Collapsed picker: base models only, no effort-suffix variants. expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("gemini-3.6-flash"); From 5ee20feef84362f0f4342336c170faf489554f4d Mon Sep 17 00:00:00 2001 From: Xinwei Gao Date: Fri, 7 Aug 2026 13:38:00 +0800 Subject: [PATCH 2/6] fix(security): guard Antigravity model discovery Route Cloud Code Assist discovery POSTs through the provider outbound policy and invalidate account-scoped live model cache when OAuth credentials change. --- src/codex/catalog/provider-fetch.ts | 6 +- src/lib/pinned-http.ts | 49 +++++++++--- src/lib/provider-outbound.ts | 51 +++++++++--- src/oauth/index.ts | 4 +- src/server/management/oauth-account-routes.ts | 6 ++ src/server/management/provider-routes.ts | 7 +- tests/oauth-accounts-api.test.ts | 27 +++++++ tests/provider-outbound.test.ts | 79 ++++++++++++++++++- 8 files changed, 199 insertions(+), 30 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 8f18f11c8d..40303fb9c5 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -49,6 +49,7 @@ import type { NormalizedComboConfig } from "../../combos/types"; import { ProviderOutboundPolicyError, providerOutboundGet, + providerOutboundPost, providerRedirectError, } from "../../lib/provider-outbound"; import { redactSecretString } from "../../lib/redact"; @@ -983,13 +984,10 @@ async function fetchProviderModelsWithAuth( }; }; try { - const providerFetch = (prov as OcxProviderConfig & { fetch?: typeof fetch }).fetch; const res = request.method === "POST" - ? await (providerFetch ?? globalThis.fetch)(url, { - method: "POST", + ? await providerOutboundPost(name, prov, url, { headers, body: JSON.stringify({ project: auth.oauthProjectId }), - redirect: "manual", signal: AbortSignal.timeout(8000), }) : await providerOutboundGet(name, prov, url, { diff --git a/src/lib/pinned-http.ts b/src/lib/pinned-http.ts index 0a6ecf2918..0b7123e4a7 100644 --- a/src/lib/pinned-http.ts +++ b/src/lib/pinned-http.ts @@ -3,7 +3,7 @@ import https from "node:https"; export type PinnedAddress = { address: string; family: number }; -export interface PinnedHttpGetOptions { +export interface PinnedHttpRequestOptions { headers?: HeadersInit; maxBytes?: number; idleTimeoutMs?: number; @@ -11,15 +11,16 @@ export interface PinnedHttpGetOptions { context?: string; } -/** - * GET a URL through one previously validated address. The original hostname - * remains authoritative for Host, SNI, and certificate verification. - */ -export function pinnedHttpGet( +/** @deprecated Use {@link PinnedHttpRequestOptions}. */ +export type PinnedHttpGetOptions = PinnedHttpRequestOptions; + +function pinnedHttpRequest( url: string, pinned: PinnedAddress, + method: "GET" | "POST", + body: string | undefined, signal?: AbortSignal, - options?: PinnedHttpGetOptions, + options?: PinnedHttpRequestOptions, ): Promise { const parsed = new URL(url); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { @@ -30,6 +31,9 @@ export function pinnedHttpGet( const maxBytes = options?.maxBytes; const headers = new Headers(options?.headers); headers.set("host", parsed.host); + if (body !== undefined && !headers.has("content-length")) { + headers.set("content-length", String(Buffer.byteLength(body))); + } const requestHeaders: Record = {}; headers.forEach((value, key) => { requestHeaders[key] = value; }); @@ -51,7 +55,7 @@ export function pinnedHttpGet( hostname: parsed.hostname, port: parsed.port || (parsed.protocol === "https:" ? 443 : 80), path: `${parsed.pathname}${parsed.search}`, - method: "GET", + method, headers: requestHeaders, ...(parsed.protocol === "https:" ? { @@ -146,6 +150,33 @@ export function pinnedHttpGet( fail(error); }); req.on("close", () => signal?.removeEventListener("abort", onAbort)); - req.end(); + req.end(body); }); } + +/** + * GET a URL through one previously validated address. The original hostname + * remains authoritative for Host, SNI, and certificate verification. + */ +export function pinnedHttpGet( + url: string, + pinned: PinnedAddress, + signal?: AbortSignal, + options?: PinnedHttpRequestOptions, +): Promise { + return pinnedHttpRequest(url, pinned, "GET", undefined, signal, options); +} + +/** + * POST a string body through one previously validated address. The original + * hostname remains authoritative for Host, SNI, and certificate verification. + */ +export function pinnedHttpPost( + url: string, + pinned: PinnedAddress, + body: string, + signal?: AbortSignal, + options?: PinnedHttpRequestOptions, +): Promise { + return pinnedHttpRequest(url, pinned, "POST", body, signal, options); +} diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index 29e712623a..67b55b4706 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -6,17 +6,19 @@ import { providerDestinationConfigError, resolvePublicAddresses, } from "./destination-policy"; -import { pinnedHttpGet } from "./pinned-http"; +import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; import { outboundProxyConfigured } from "./proxy-env"; import { publicProviderBaseUrl } from "./provider-url"; type ProviderGetInit = Omit; +type ProviderPostInit = ProviderGetInit & { body: string }; type ProviderOutboundConfig = Pick & { fetch?: typeof globalThis.fetch; }; export interface ProviderOutboundDependencies { resolveAddresses?: typeof resolvePublicAddresses; pinnedGet?: typeof pinnedHttpGet; + pinnedPost?: typeof pinnedHttpPost; } export class ProviderOutboundPolicyError extends Error { @@ -102,13 +104,18 @@ export async function providerRedirectError(response: Response, requestUrl: stri return `provider returned ${response.status} redirect to ${target}; configure the final provider URL directly`; } -export async function providerOutboundGet( +async function providerOutboundRequest( name: string, provider: ProviderOutboundConfig, url: string, - init: ProviderGetInit = {}, + method: "GET" | "POST", + init: ProviderGetInit | ProviderPostInit, dependencies: ProviderOutboundDependencies = {}, ): Promise { + const postUrl = method === "POST" ? new URL(url) : undefined; + if (postUrl?.protocol !== undefined && postUrl.protocol !== "https:") { + throw new ProviderOutboundPolicyError("provider POST URL must use HTTPS"); + } if (provider.fetch) { // A caller-owned executor cannot be peer-pinned here. This branch keeps literal/config // checks and redirect blocking, but does not provide the resolved-address guarantees of @@ -128,12 +135,13 @@ export async function providerOutboundGet( }); if (destinationError) throw new ProviderOutboundPolicyError(destinationError); } - return provider.fetch(url, { ...init, method: "GET", redirect: "manual" }); + return provider.fetch(url, { ...init, method, redirect: "manual" }); } - const parsed = new URL(url); + const parsed = postUrl ?? new URL(url); const proxyConfigured = configuredProxyFor(); const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses; const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet; + const pinnedPost = dependencies.pinnedPost ?? pinnedHttpPost; const allowPrivate = providerAllowsPrivateNetwork(name, provider); let resolved: Awaited>; try { @@ -150,11 +158,11 @@ export async function providerOutboundGet( if (!proxyConfigured) throw error; warnProxyBoundaryOnce(); warnProxyDnsDegradationOnce(); - return globalThis.fetch(url, { ...init, method: "GET", redirect: "manual" }); + return globalThis.fetch(url, { ...init, method, redirect: "manual" }); } if (proxyConfigured && !resolved.privateNetwork) { warnProxyBoundaryOnce(); - return globalThis.fetch(url, { ...init, method: "GET", redirect: "manual" }); + return globalThis.fetch(url, { ...init, method, redirect: "manual" }); } if (proxyConfigured && resolved.privateNetwork && !noProxyMatches(parsed)) { const hostname = normalizeProxyHostname(parsed.hostname); @@ -162,9 +170,34 @@ export async function providerOutboundGet( `provider URL resolves to a private-network destination; add ${hostname} to NO_PROXY before using allowPrivateNetwork with an outbound proxy`, ); } - return pinnedGet(url, pickPinnedAddress(resolved.addresses), init.signal ?? undefined, { + const requestOptions = { headers: init.headers, rejectUnauthorized: true, context: "provider response", - }); + }; + const pinned = pickPinnedAddress(resolved.addresses); + if (method === "POST") { + return pinnedPost(url, pinned, (init as ProviderPostInit).body, init.signal ?? undefined, requestOptions); + } + return pinnedGet(url, pinned, init.signal ?? undefined, requestOptions); +} + +export async function providerOutboundGet( + name: string, + provider: ProviderOutboundConfig, + url: string, + init: ProviderGetInit = {}, + dependencies: ProviderOutboundDependencies = {}, +): Promise { + return providerOutboundRequest(name, provider, url, "GET", init, dependencies); +} + +export async function providerOutboundPost( + name: string, + provider: ProviderOutboundConfig, + url: string, + init: ProviderPostInit, + dependencies: ProviderOutboundDependencies = {}, +): Promise { + return providerOutboundRequest(name, provider, url, "POST", init, dependencies); } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index a537869d6e..f1e9354f4e 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1003,11 +1003,13 @@ export async function runLogin( settleKiroTransaction(rawCred, true); if (provider !== "chatgpt") { try { + const { clearModelCache } = await import("../codex/model-cache"); + clearModelCache(provider); const { clearAccountQuotaCache, clearProviderQuotaCache } = await import("../providers/quota"); clearProviderQuotaCache(); clearAccountQuotaCache(provider); } catch { - // Quota module may be unavailable in tightly scoped unit tests. + // Optional state modules may be unavailable in tightly scoped unit tests. } } return cred; diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index f3db21c6a6..88b0026281 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -210,6 +210,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< await removeCredential(provider); reconcileLiveStateStores(); clearLoginState(provider); + const { clearModelCache } = await import("../../codex/model-cache"); + clearModelCache(provider); // Drop cached/last-good quota rows tied to the removed credential. const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); @@ -281,6 +283,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const { resetAnthropicRoutingForManualSelection } = await import("../../oauth/anthropic-routing"); resetAnthropicRoutingForManualSelection(body.accountId); } + const { clearModelCache } = await import("../../codex/model-cache"); + clearModelCache(provider); const { clearProviderQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); return jsonResponse({ ok: true, provider, activeAccountId: body.accountId }); @@ -404,6 +408,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< clearAnthropicSessionAffinityForAccount(id); } if (!getAccountSet(provider)) clearLoginState(provider); + const { clearModelCache } = await import("../../codex/model-cache"); + clearModelCache(provider); const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); clearAccountQuotaCache(provider); diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 3d462fff77..cc34065f9c 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -25,7 +25,7 @@ import { import { removeCredential } from "../../oauth/store"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; -import { ProviderOutboundPolicyError, providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound"; +import { ProviderOutboundPolicyError, providerOutboundGet, providerOutboundPost, providerRedirectError } from "../../lib/provider-outbound"; import { parseAntigravityAvailableModels } from "../../providers/antigravity-models"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets } from "../../providers/derive"; @@ -523,13 +523,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { }); afterEach(() => { + clearModelCache("google-antigravity"); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; isolatedCodexHome?.restore(); @@ -175,6 +177,31 @@ describe("multiauth accounts API", () => { } }); + test("switching an Antigravity account clears its account-scoped live-model cache", async () => { + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ + "google-antigravity": { + activeAccountId: "antigravity-a", + accounts: [ + { id: "antigravity-a", credential: { access: "a", refresh: "ra", expires: 9999999999999, projectId: "project-a" } }, + { id: "antigravity-b", credential: { access: "b", refresh: "rb", expires: 9999999999999, projectId: "project-b" } }, + ], + }, + }), { mode: 0o600 }); + const server = startServer(0); + try { + setCached("google-antigravity", [{ provider: "google-antigravity", id: "account-a-only-model" }]); + const response = await fetch(new URL("/api/oauth/accounts/active", server.url), { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "google-antigravity", accountId: "antigravity-b" }), + }); + + expect(response.status).toBe(200); + expect(getStaleCached("google-antigravity")).toBeNull(); + } finally { + await server.stop(true); + } + }); + test("DELETE removes one account; active removal promotes the other", async () => { const server = startServer(0); try { diff --git a/tests/provider-outbound.test.ts b/tests/provider-outbound.test.ts index 2fc871208e..8f5e944f24 100644 --- a/tests/provider-outbound.test.ts +++ b/tests/provider-outbound.test.ts @@ -21,9 +21,9 @@ function directDependencies( options?: { privateNetwork?: boolean; address?: string }, ): { dependencies: ProviderOutboundDependencies; - captured: { address?: string; rejectUnauthorized?: boolean; authorization?: string }; + captured: { address?: string; rejectUnauthorized?: boolean; authorization?: string; body?: string }; } { - const captured: { address?: string; rejectUnauthorized?: boolean; authorization?: string } = {}; + const captured: { address?: string; rejectUnauthorized?: boolean; authorization?: string; body?: string } = {}; const address = options?.address ?? "93.184.216.34"; return { captured, @@ -39,6 +39,13 @@ function directDependencies( captured.authorization = new Headers(requestOptions?.headers).get("authorization") ?? undefined; return response; }), + pinnedPost: mock(async (_url, pinned, body, _signal, requestOptions) => { + captured.address = pinned.address; + captured.rejectUnauthorized = requestOptions?.rejectUnauthorized; + captured.authorization = new Headers(requestOptions?.headers).get("authorization") ?? undefined; + captured.body = body; + return response; + }), }, }; } @@ -238,3 +245,71 @@ describe("provider outbound GET transport", () => { } }, 15_000); }); + +describe("provider outbound POST transport", () => { + test("direct HTTPS posts only to the validated address with its credential and body", async () => { + for (const key of proxyKeys) delete process.env[key]; + const { providerOutboundPost } = await import("../src/lib/provider-outbound"); + const { dependencies, captured } = directDependencies(new Response('{"models":{}}', { + status: 200, + headers: { "content-type": "application/json" }, + })); + const body = JSON.stringify({ project: "test-project" }); + + const response = await providerOutboundPost( + "google-antigravity", + { baseUrl: "https://provider.example" }, + "https://provider.example/v1internal:fetchAvailableModels", + { headers: { authorization: "Bearer test-token" }, body }, + dependencies, + ); + + expect(await response.json()).toEqual({ models: {} }); + expect(captured).toEqual({ + address: "93.184.216.34", + rejectUnauthorized: true, + authorization: "Bearer test-token", + body, + }); + }); + + test("blocks an unsafe POST destination before invoking a caller-owned executor", async () => { + const { providerOutboundPost, ProviderOutboundPolicyError } = await import("../src/lib/provider-outbound"); + let calls = 0; + const provider = { + baseUrl: "https://provider.example", + fetch: (async () => { + calls += 1; + return new Response("{}"); + }) as typeof fetch, + }; + + await expect(providerOutboundPost( + "google-antigravity", + provider, + "https://169.254.169.254/v1internal:fetchAvailableModels", + { headers: { authorization: "Bearer test-token" }, body: '{"project":"test-project"}' }, + )).rejects.toThrow(ProviderOutboundPolicyError); + expect(calls).toBe(0); + }); + + test("requires HTTPS before invoking a caller-owned executor", async () => { + const { providerOutboundPost, ProviderOutboundPolicyError } = await import("../src/lib/provider-outbound"); + let calls = 0; + const provider = { + baseUrl: "https://provider.example", + fetch: (async () => { + calls += 1; + return new Response("{}"); + }) as typeof fetch, + }; + + await expect(providerOutboundPost( + "google-antigravity", + provider, + "http://93.184.216.34/v1internal:fetchAvailableModels", + { headers: { authorization: "Bearer test-token" }, body: '{"project":"test-project"}' }, + )).rejects.toThrow(ProviderOutboundPolicyError); + expect(calls).toBe(0); + }); +}); From e0e3e38e47c1e4382987843028d9f19422b64d34 Mon Sep 17 00:00:00 2001 From: Xinwei Gao Date: Fri, 7 Aug 2026 14:11:42 +0800 Subject: [PATCH 3/6] fix(google): scope Antigravity discovery to current account Discard stale discovery writes after OAuth account changes, use the routed CCA project for discovery, and retain partial Gemini availability as explicit wire IDs. --- src/codex/catalog/provider-fetch.ts | 35 ++++++--- src/codex/model-cache.ts | 29 ++++++- src/oauth/index.ts | 2 + src/providers/antigravity-models.ts | 26 +++++-- src/server/management/oauth-account-routes.ts | 6 ++ src/server/management/provider-routes.ts | 5 +- tests/google-antigravity-wire.test.ts | 22 +++++- tests/google-models-listing.test.ts | 23 +++--- tests/oauth-accounts-api.test.ts | 77 +++++++++++++++++++ tests/provider-connection-test.test.ts | 7 +- 10 files changed, 201 insertions(+), 31 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 40303fb9c5..904cad6f28 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -7,10 +7,12 @@ import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readR import { clearModelCache, clearProviderDiscoveryStatus, + captureModelCacheGeneration, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, + isModelCacheGenerationCurrent, markModelsFetchFailure, markProviderDiscoveryFailed, markProviderDiscoveryOk, @@ -852,6 +854,10 @@ async function fetchProviderModelsWithAuth( models: CatalogModel[], state: CatalogGatherProviderModelOutcome["state"], ): ProviderModelsResult => ({ models, outcome: { provider: name, state } }); + // Capture before any credential refresh or outbound await. OAuth account changes clear this + // generation, so a request started with the former account cannot later publish its result. + const cacheGeneration = captureModelCacheGeneration(name); + const isCurrentCacheGeneration = () => isModelCacheGenerationCurrent(name, cacheGeneration); if (prov.authMode === "forward") return observed([], "authoritative"); // ChatGPT backend has no /models const seedVertexDefault = prov.adapter === "google" && prov.googleMode === "vertex" @@ -920,12 +926,14 @@ async function fetchProviderModelsWithAuth( const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models); const result = available.length > 0 ? available : configured; // Count what discovery actually returned, not the configured rows we fall back to. + if (!setCached(name, result, Date.now(), cacheGeneration)) return observed(configured, "degraded"); markProviderDiscoveryOk(name, liveResult.models.length); - setCached(name, result); return observed(result, "authoritative"); } - markModelsFetchFailure(name); - markProviderDiscoveryFailed(name, { reason: "provider" }); + if (isCurrentCacheGeneration()) { + markModelsFetchFailure(name); + markProviderDiscoveryFailed(name, { reason: "provider" }); + } console.warn( `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`, ); @@ -941,7 +949,9 @@ async function fetchProviderModelsWithAuth( // matching Cursor's !apiKey → configured degradation and fetch-failure fallback. return observed(configured, "degraded"); } - if (effectiveGoogleMode(name, prov) === "cloud-code-assist" && !auth.oauthProjectId) return configured; + const cloudCodeAssist = effectiveGoogleMode(name, prov) === "cloud-code-assist"; + const project = prov.project ?? auth.oauthProjectId; + if (cloudCodeAssist && !project) return observed(configured, "degraded"); const fresh = getFreshCached(name, ttlMs); if (fresh) { return observed( @@ -968,6 +978,9 @@ async function fetchProviderModelsWithAuth( const failedDiscoveryFallback = ( failure: ProviderModelDiscoveryFailure, ): { models: CatalogModel[]; fallback: "stale" | "configured"; shouldLog: boolean } => { + if (!isCurrentCacheGeneration()) { + return { models: failedDiscoveryConfigured, fallback: "configured", shouldLog: false }; + } // Decide logging BEFORE recording the new status, so we can compare against the prior one and // suppress an identical repeated failure (#395 log flood). The failure stays observable via the // discovery-status API regardless. @@ -987,7 +1000,7 @@ async function fetchProviderModelsWithAuth( const res = request.method === "POST" ? await providerOutboundPost(name, prov, url, { headers, - body: JSON.stringify({ project: auth.oauthProjectId }), + body: JSON.stringify({ project }), signal: AbortSignal.timeout(8000), }) : await providerOutboundGet(name, prov, url, { @@ -1032,17 +1045,17 @@ async function fetchProviderModelsWithAuth( } return observed(models, "degraded"); } - const antigravity = effectiveGoogleMode(name, prov) === "cloud-code-assist" + const antigravity = cloudCodeAssist ? parseAntigravityAvailableModels(bounded.value) : undefined; - if (effectiveGoogleMode(name, prov) === "cloud-code-assist" && !antigravity) { + if (cloudCodeAssist && !antigravity) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); if (shouldLog) { console.warn( `[opencodex] Provider model discovery for "${name}" returned malformed CCA model data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, ); } - return models; + return observed(models, "degraded"); } if (antigravity) { const live = antigravity.map(model => applyProviderConfigHints(name, prov, { @@ -1054,9 +1067,9 @@ async function fetchProviderModelsWithAuth( ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}), ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}), }, contextCap)); + if (!setCached(name, live, Date.now(), cacheGeneration)) return observed(configured, "degraded"); markProviderDiscoveryOk(name, live.length); - setCached(name, live); - return live; + return observed(live, "authoritative"); } const extracted = extractProviderModelItems(bounded.value, discovery); if (!extracted.ok) { @@ -1116,8 +1129,8 @@ async function fetchProviderModelsWithAuth( && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name)) { warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds); } + if (!setCached(name, live, Date.now(), cacheGeneration)) return observed(configured, "degraded"); markProviderDiscoveryOk(name, liveModelCount); - setCached(name, live); return observed(live, "authoritative"); } catch (error) { if (error instanceof ProviderOutboundPolicyError) { diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts index e6d3916799..83cf52ae6d 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -43,6 +43,8 @@ export type ProviderModelDiscoveryFailure = ProviderModelDiscoveryStatus extends : never; const cache = new Map(); +let globalCacheGeneration = 0; +const providerCacheGenerations = new Map(); let cacheBytes = 0; let oldestCachedProvider: string | undefined; let oldestCachedAt: number | null = null; @@ -155,7 +157,28 @@ export function getStaleCached(provider: string): CatalogModel[] | null { return cache.get(provider)?.models ?? null; } -export function setCached(provider: string, models: CatalogModel[], now = Date.now()): void { +/** Capture the cache generation before an asynchronous provider discovery starts. */ +export function captureModelCacheGeneration(provider: string): string { + return `${globalCacheGeneration}:${providerCacheGenerations.get(provider) ?? 0}`; +} + +/** Whether a discovery started under {@link captureModelCacheGeneration} may still write. */ +export function isModelCacheGenerationCurrent(provider: string, generation: string): boolean { + return generation === captureModelCacheGeneration(provider); +} + +/** + * Store a live result unless the cache was cleared while that asynchronous discovery was running. + * The optional generation keeps existing direct cache writers unchanged while discovery callers can + * prevent a previous OAuth account from repopulating the current account's cache. + */ +export function setCached( + provider: string, + models: CatalogModel[], + now = Date.now(), + generation?: string, +): boolean { + if (generation !== undefined && !isModelCacheGenerationCurrent(provider, generation)) return false; deleteCachedProvider(provider); const sizeBytes = modelCacheEncoder.encode(provider).byteLength + modelCacheEncoder.encode(JSON.stringify(models)).byteLength; @@ -166,16 +189,19 @@ export function setCached(provider: string, models: CatalogModel[], now = Date.n oldestCachedAt = now; } enforceAppOwnedMemoryBudget(); + return true; } /** Drop one provider's cache (or all) so the next resolve forces a live re-fetch. */ export function clearModelCache(provider?: string): void { if (provider) { + providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1); deleteCachedProvider(provider); failureAt.delete(provider); discoveryStatus.delete(provider); liveModelCounts.delete(provider); } else { + globalCacheGeneration += 1; cache.clear(); cacheBytes = 0; oldestCachedProvider = undefined; @@ -201,6 +227,7 @@ export function reconcileModelCacheProviders( } for (const provider of cache.keys()) { if (validProviders.has(provider)) continue; + providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1); deleteCachedProvider(provider); removedProviders.add(provider); } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index f1e9354f4e..a7cd8ecdd5 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1004,7 +1004,9 @@ export async function runLogin( if (provider !== "chatgpt") { try { const { clearModelCache } = await import("../codex/model-cache"); + const { clearGatherRoutedModelsInflight } = await import("../codex/catalog"); clearModelCache(provider); + clearGatherRoutedModelsInflight(); const { clearAccountQuotaCache, clearProviderQuotaCache } = await import("../providers/quota"); clearProviderQuotaCache(); clearAccountQuotaCache(provider); diff --git a/src/providers/antigravity-models.ts b/src/providers/antigravity-models.ts index 8588d7e2c3..060b938325 100644 --- a/src/providers/antigravity-models.ts +++ b/src/providers/antigravity-models.ts @@ -4,7 +4,8 @@ // CLI resolves labels against. The ids below separate CCA wire ids, collapsed picker entries, // and hidden compatibility aliases for saved selections. The CCA envelope's `model` field must // receive the wire id (for example "Gemini 3.1 Pro (High)" => gemini-pro-agent), while the -// picker exposes collapsed base models with reasoning-effort routing. +// picker exposes collapsed base models only when CCA returns every known tier; otherwise each +// returned wire id remains visible so an unavailable tier cannot be selected. // ── Wire IDs (what CCA :fetchAvailableModels returns) ── const ANTIGRAVITY_WIRE_MODELS = [ @@ -27,6 +28,13 @@ const ANTIGRAVITY_PICKER_MODEL_BY_WIRE_ID: Record = { "gemini-pro-agent": "gemini-3.1-pro", }; +const ANTIGRAVITY_WIRE_IDS_BY_PICKER_MODEL: Record = Object.entries( + ANTIGRAVITY_PICKER_MODEL_BY_WIRE_ID, +).reduce>((out, [wireId, pickerId]) => { + (out[pickerId] ??= []).push(wireId); + return out; +}, {}); + // ── Effort ladders per collapsed base model ── // Gemini models: effort → wire model suffix (official agy UI pattern). // Claude Opus: effort → thinkingConfig.thinkingLevel (CLIProxyAPI proven pattern). @@ -183,15 +191,23 @@ export function parseAntigravityAvailableModels(payload: unknown): AntigravityAv ids.push("gemini-3.1-flash-image"); } - const out: AntigravityAvailableModel[] = []; - const seen = new Set(); + const available = new Map>(); for (const wireId of ids) { const info = antigravityRecord(models[wireId]); - if (!info) continue; - const id = ANTIGRAVITY_PICKER_MODEL_BY_WIRE_ID[wireId] ?? wireId; + if (!info || available.has(wireId)) continue; // Legacy compatibility aliases are deliberately routed to newer wire ids for saved // selections. They are not safe as independently discovered picker rows. if (ANTIGRAVITY_MODEL_ALIASES[wireId] && ANTIGRAVITY_MODEL_ALIASES[wireId] !== wireId) continue; + available.set(wireId, info); + } + + const out: AntigravityAvailableModel[] = []; + const seen = new Set(); + for (const [wireId, info] of available) { + const pickerId = ANTIGRAVITY_PICKER_MODEL_BY_WIRE_ID[wireId]; + const completePickerSet = pickerId !== undefined + && ANTIGRAVITY_WIRE_IDS_BY_PICKER_MODEL[pickerId]!.every(id => available.has(id)); + const id = completePickerSet ? pickerId! : wireId; if (seen.has(id)) continue; seen.add(id); out.push({ diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 88b0026281..3663d53a2e 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -211,7 +211,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< reconcileLiveStateStores(); clearLoginState(provider); const { clearModelCache } = await import("../../codex/model-cache"); + const { clearGatherRoutedModelsInflight } = await import("../../codex/catalog"); clearModelCache(provider); + clearGatherRoutedModelsInflight(); // Drop cached/last-good quota rows tied to the removed credential. const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); @@ -284,7 +286,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< resetAnthropicRoutingForManualSelection(body.accountId); } const { clearModelCache } = await import("../../codex/model-cache"); + const { clearGatherRoutedModelsInflight } = await import("../../codex/catalog"); clearModelCache(provider); + clearGatherRoutedModelsInflight(); const { clearProviderQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); return jsonResponse({ ok: true, provider, activeAccountId: body.accountId }); @@ -409,7 +413,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< } if (!getAccountSet(provider)) clearLoginState(provider); const { clearModelCache } = await import("../../codex/model-cache"); + const { clearGatherRoutedModelsInflight } = await import("../../codex/catalog"); clearModelCache(provider); + clearGatherRoutedModelsInflight(); const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); clearAccountQuotaCache(provider); diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index cc34065f9c..d2a8f369c3 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -516,7 +516,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { } }); + test("collapses a complete CCA Gemini tier set but retains partial sets as wire IDs", () => { + const payload = (modelIds: string[]) => ({ + models: Object.fromEntries(modelIds.map(id => [id, { maxTokens: 1_048_576 }])), + agentModelSorts: [{ groups: [{ modelIds }] }], + }); + + expect(parseAntigravityAvailableModels(payload([ + "gemini-3.6-flash-low", + "gemini-3.6-flash-medium", + "gemini-3.6-flash-high", + ]))?.map(model => model.id)).toEqual(["gemini-3.6-flash"]); + expect(parseAntigravityAvailableModels(payload([ + "gemini-3.6-flash-low", + "gemini-3.6-flash-high", + ]))?.map(model => model.id)).toEqual([ + "gemini-3.6-flash-low", + "gemini-3.6-flash-high", + ]); + }); + test("throws when no project id is available", async () => { const noProj = { ...provider, project: undefined } as OcxProviderConfig; await expect(createGoogleAdapter(noProj).buildRequest(parsed())).rejects.toThrow(/project id/); diff --git a/tests/google-models-listing.test.ts b/tests/google-models-listing.test.ts index 7c3aa8588e..c6cf04453c 100644 --- a/tests/google-models-listing.test.ts +++ b/tests/google-models-listing.test.ts @@ -107,6 +107,7 @@ describe("Antigravity live model discovery", () => { adapter: "google", authMode: "oauth", baseUrl: "https://daily-cloudcode-pa.googleapis.com", + project: "configured-project", liveModels: true, models: ["configured-only"], })); @@ -116,16 +117,17 @@ describe("Antigravity live model discovery", () => { expect(seen[0]?.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"); expect(seen[0]?.init?.method).toBe("POST"); expect((seen[0]?.init?.headers as Record).Authorization).toBe("Bearer access-token"); - expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({ project: "project-id" }); + expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({ project: "configured-project" }); expect(live.map(model => model.id).sort()).toEqual([ "future-agent-model", "gemini-3.1-flash-image", - "gemini-3.6-flash", + "gemini-3.6-flash-high", + "gemini-3.6-flash-low", ]); - expect(live.find(model => model.id === "gemini-3.6-flash")).toMatchObject({ + expect(live.find(model => model.id === "gemini-3.6-flash-low")).toMatchObject({ contextWindow: 1_048_576, inputModalities: ["text", "image"], - reasoningEfforts: ["low", "medium", "high"], + reasoningEfforts: [], }); expect(live.find(model => model.id === "future-agent-model")).toMatchObject({ contextWindow: 333_333, @@ -136,17 +138,20 @@ describe("Antigravity live model discovery", () => { expect(live.map(model => model.id)).not.toContain("non-agent-command-model"); const catalog = buildCatalogEntries(null, [], live); - const flash = catalog.find(entry => entry.slug === "google-antigravity/gemini-3.6-flash"); + const flashLow = catalog.find(entry => entry.slug === "google-antigravity/gemini-3.6-flash-low"); + const flashHigh = catalog.find(entry => entry.slug === "google-antigravity/gemini-3.6-flash-high"); const future = catalog.find(entry => entry.slug === "google-antigravity/future-agent-model"); - expect(flash).toMatchObject({ + expect(flashLow).toMatchObject({ context_window: 1_048_576, max_context_window: 1_048_576, auto_compact_token_limit: 943_718, input_modalities: ["text", "image"], - default_reasoning_level: "medium", }); - expect((flash?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) - .toEqual(["low", "medium", "high", "max", "ultra"]); + expect(flashLow).not.toHaveProperty("default_reasoning_level"); + expect(flashLow?.supported_reasoning_levels).toEqual([]); + expect(flashHigh).toBeDefined(); + expect(catalog.map(entry => entry.slug)).not.toContain("google-antigravity/gemini-3.6-flash"); + expect(catalog.map(entry => entry.slug)).not.toContain("google-antigravity/gemini-3.6-flash-medium"); expect(future).toMatchObject({ context_window: 333_333, max_context_window: 333_333, diff --git a/tests/oauth-accounts-api.test.ts b/tests/oauth-accounts-api.test.ts index a2b32a58f0..280e8cfb3a 100644 --- a/tests/oauth-accounts-api.test.ts +++ b/tests/oauth-accounts-api.test.ts @@ -5,13 +5,19 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; import { startServer } from "../src/server"; +import { gatherRoutedModels as gatherRoutedModelsDirect } from "../src/codex/catalog"; import { clearModelCache, getStaleCached, setCached } from "../src/codex/model-cache"; import type { OcxConfig } from "../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; let testDir = ""; let previousHome: string | undefined; let isolatedCodexHome: IsolatedCodexHome | null = null; +const originalFetch = globalThis.fetch; + +const gatherRoutedModels: typeof gatherRoutedModelsDirect = config => + gatherRoutedModelsDirect(withStubbedProviderFetch(config)); function baseConfig(): OcxConfig { return { @@ -46,6 +52,7 @@ beforeEach(() => { }); afterEach(() => { + globalThis.fetch = originalFetch; clearModelCache("google-antigravity"); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; @@ -202,6 +209,76 @@ describe("multiauth accounts API", () => { } }); + test("switching an Antigravity account discards an in-flight prior-account discovery", async () => { + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ + "google-antigravity": { + activeAccountId: "antigravity-a", + accounts: [ + { id: "antigravity-a", credential: { access: "account-a-token", refresh: "ra", expires: 9999999999999, projectId: "project-a" } }, + { id: "antigravity-b", credential: { access: "account-b-token", refresh: "rb", expires: 9999999999999, projectId: "project-b" } }, + ], + }, + }), { mode: 0o600 }); + const config = { + providers: { + "google-antigravity": { + adapter: "google", + authMode: "oauth", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + liveModels: true, + }, + }, + } as unknown as OcxConfig; + let releaseAccountA!: () => void; + const accountAStarted = new Promise(resolve => { + releaseAccountA = resolve; + }); + let accountAFetchStarted!: () => void; + const accountAFetchObserved = new Promise(resolve => { + accountAFetchStarted = resolve; + }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).includes("/api/")) return originalFetch(input, init); + const authorization = new Headers(init?.headers).get("Authorization"); + expect(authorization).toBe("Bearer account-a-token"); + accountAFetchStarted(); + await accountAStarted; + return Response.json({ + models: { "account-a-model": { maxTokens: 16_384 } }, + agentModelSorts: [{ groups: [{ modelIds: ["account-a-model"] }] }], + }); + }) as typeof fetch; + + const server = startServer(0); + try { + const first = gatherRoutedModels(config); + await accountAFetchObserved; + const switched = await fetch(new URL("/api/oauth/accounts/active", server.url), { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "google-antigravity", accountId: "antigravity-b" }), + }); + expect(switched.status).toBe(200); + releaseAccountA(); + await first; + expect(getStaleCached("google-antigravity")).toBeNull(); + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).includes("/api/")) return originalFetch(input, init); + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer account-b-token"); + expect(JSON.parse(String(init?.body))).toEqual({ project: "project-b" }); + return Response.json({ + models: { "account-b-model": { maxTokens: 32_768 } }, + agentModelSorts: [{ groups: [{ modelIds: ["account-b-model"] }] }], + }); + }) as typeof fetch; + const second = await gatherRoutedModels(config); + expect(second.map(model => model.id)).toEqual(["account-b-model"]); + expect(getStaleCached("google-antigravity")?.map(model => model.id)).toEqual(["account-b-model"]); + } finally { + await server.stop(true); + } + }); + test("DELETE removes one account; active removal promotes the other", async () => { const server = startServer(0); try { diff --git a/tests/provider-connection-test.test.ts b/tests/provider-connection-test.test.ts index db9ec5ae68..c33f1ee170 100644 --- a/tests/provider-connection-test.test.ts +++ b/tests/provider-connection-test.test.ts @@ -137,7 +137,10 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { projectId: "test-project-id", }); const config = baseConfig({ - "google-antigravity": structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig), + "google-antigravity": { + ...structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig), + project: "configured-project", + }, }); const { body } = await probe(config, "google-antigravity"); @@ -147,7 +150,7 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { expect(seen[0]?.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"); expect(seen[0]?.init?.method).toBe("POST"); expect((seen[0]?.init?.headers as Record).Authorization).toBe("Bearer test-access-token"); - expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({ project: "test-project-id" }); + expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({ project: "configured-project" }); }); test("a fake key gets the upstream rejection, not a catalog-presence pass", async () => { From 22f20ca20d4363b81e5688529c563de0bde015f8 Mon Sep 17 00:00:00 2001 From: Xinwei Gao Date: Fri, 7 Aug 2026 14:55:21 +0800 Subject: [PATCH 4/6] fix(google): harden Antigravity catalog discovery Invalidate removed provider generations, migrate canonical static opt-outs, and reject malformed CCA model identifiers. --- src/codex/catalog/provider-fetch.ts | 8 ++--- src/codex/model-cache.ts | 22 +++++++------ src/config.ts | 9 +++--- src/oauth/index.ts | 32 +++++++++++++++++- src/providers/antigravity-models.ts | 11 +++++-- src/providers/model-discovery-limits.ts | 16 +++++++++ src/providers/model-discovery.ts | 29 ++++++++--------- src/server/management/provider-routes.ts | 2 +- src/types.ts | 4 +-- tests/config.test.ts | 8 ++--- tests/cursor-hardening.test.ts | 38 ++++++++++++++++++++++ tests/google-antigravity-wire.test.ts | 18 +++++++++++ tests/google-models-listing.test.ts | 41 ++++++++++++++++++++++++ tests/model-cache.test.ts | 22 +++++++++++++ tests/oauth-provider-reconcile.test.ts | 24 ++++++++++++-- 15 files changed, 237 insertions(+), 47 deletions(-) create mode 100644 src/providers/model-discovery-limits.ts create mode 100644 tests/model-cache.test.ts diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 904cad6f28..c1fc71fb9e 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -933,10 +933,10 @@ async function fetchProviderModelsWithAuth( if (isCurrentCacheGeneration()) { markModelsFetchFailure(name); markProviderDiscoveryFailed(name, { reason: "provider" }); + console.warn( + `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`, + ); } - console.warn( - `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`, - ); const staleCursor = getStaleCached(name); return observed( staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured, @@ -1046,7 +1046,7 @@ async function fetchProviderModelsWithAuth( return observed(models, "degraded"); } const antigravity = cloudCodeAssist - ? parseAntigravityAvailableModels(bounded.value) + ? parseAntigravityAvailableModels(bounded.value, discovery.maxModels) : undefined; if (cloudCodeAssist && !antigravity) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts index 83cf52ae6d..700537fc64 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -159,7 +159,8 @@ export function getStaleCached(provider: string): CatalogModel[] | null { /** Capture the cache generation before an asynchronous provider discovery starts. */ export function captureModelCacheGeneration(provider: string): string { - return `${globalCacheGeneration}:${providerCacheGenerations.get(provider) ?? 0}`; + if (!providerCacheGenerations.has(provider)) providerCacheGenerations.set(provider, 0); + return `${globalCacheGeneration}:${providerCacheGenerations.get(provider)!}`; } /** Whether a discovery started under {@link captureModelCacheGeneration} may still write. */ @@ -218,17 +219,20 @@ export function reconcileModelCacheProviders( ): number { if (generation <= lastReconciledGeneration) return 0; const removedProviders = new Set(); - for (const store of [failureAt, discoveryStatus, liveModelCounts]) { - for (const provider of store.keys()) { - if (validProviders.has(provider)) continue; - store.delete(provider); - removedProviders.add(provider); - } - } - for (const provider of cache.keys()) { + const trackedProviders = new Set([ + ...providerCacheGenerations.keys(), + ...failureAt.keys(), + ...discoveryStatus.keys(), + ...liveModelCounts.keys(), + ...cache.keys(), + ]); + for (const provider of trackedProviders) { if (validProviders.has(provider)) continue; providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1); deleteCachedProvider(provider); + failureAt.delete(provider); + discoveryStatus.delete(provider); + liveModelCounts.delete(provider); removedProviders.add(provider); } lastReconciledGeneration = generation; diff --git a/src/config.ts b/src/config.ts index 15e99f28b2..b662959bd9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1057,9 +1057,8 @@ const configSchema = z.object({ providers: z.record(z.string(), providerConfigSchema), defaultProvider: z.string().min(1).default("openai"), openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(), - // Invalid hand edits must not discard an otherwise usable config. Treat them as - // pre-migration so startup can safely re-run the one-time normalization. - googleAntigravityStaticCatalogVersion: z.literal(1).optional().catch(undefined), + // Invalid hand edits must not discard an otherwise usable config. + googleAntigravityStaticCatalogVersion: z.union([z.literal(1), z.literal(2)]).optional().catch(undefined), clientIntegrations: clientIntegrationsSchema.optional().catch(undefined), providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), contextCapValue: z.number().int().positive().optional(), @@ -2004,8 +2003,8 @@ function googleAntigravityStaticCatalogVersionError(value: unknown): string | nu const raw = rawConfigRecord(value); if (!raw || !Object.hasOwn(raw, "googleAntigravityStaticCatalogVersion")) return null; const version = raw.googleAntigravityStaticCatalogVersion; - if (version === undefined || version === 1) return null; - return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1 or omitted"; + if (version === undefined || version === 1 || version === 2) return null; + return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1, 2, or omitted"; } function codexAccountPickerEnabledError(value: unknown): string | null { diff --git a/src/oauth/index.ts b/src/oauth/index.ts index a7cd8ecdd5..778ba33069 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -741,6 +741,9 @@ const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [ "preserveReasoningContentModels", ]; +const GOOGLE_ANTIGRAVITY_PROVIDER = "google-antigravity"; +const GOOGLE_ANTIGRAVITY_LIVE_DISCOVERY_VERSION = 2 as const; + /** Only migrate the three-model experimental seed; an operator's later `liveModels: false` wins. */ function isLegacyCommandCodeStaticCatalog(provider: OcxProviderConfig): boolean { return provider.liveModels === false @@ -748,8 +751,34 @@ function isLegacyCommandCodeStaticCatalog(provider: OcxProviderConfig): boolean && JSON.stringify(provider.models) === JSON.stringify(["deepseek-v4-flash", "kimi-k3", "glm-5.2"]); } +function isLegacyAntigravityStaticCatalog(provider: OcxProviderConfig): boolean { + return provider.liveModels === false + && provider.adapter === "google" + && provider.baseUrl === "https://daily-cloudcode-pa.googleapis.com" + && provider.authMode === "oauth" + && provider.googleMode === "cloud-code-assist" + && provider.defaultModel === "gemini-3.6-flash" + && JSON.stringify(provider.models) === JSON.stringify([ + "gemini-3.6-flash", + "gemini-3.1-pro", + "gemini-3.1-flash-image", + "claude-sonnet-4-6", + "claude-opus-4-6-thinking", + "gpt-oss-120b-medium", + ]); +} + +/** Promote only the versioned canonical static seed; unmarked `liveModels: false` remains user intent. */ +function migrateLegacyAntigravityStaticCatalog(config: OcxConfig): boolean { + if (config.googleAntigravityStaticCatalogVersion !== 1) return false; + const provider = config.providers[GOOGLE_ANTIGRAVITY_PROVIDER]; + if (provider && isLegacyAntigravityStaticCatalog(provider)) provider.liveModels = true; + config.googleAntigravityStaticCatalogVersion = GOOGLE_ANTIGRAVITY_LIVE_DISCOVERY_VERSION; + return true; +} + export function reconcileOAuthProviders(config: OcxConfig): boolean { - let changed = false; + let changed = migrateLegacyAntigravityStaticCatalog(config); for (const [name, prov] of Object.entries(config.providers)) { const def = OAUTH_PROVIDERS[name]; if (name === "command-code" && isLegacyCommandCodeStaticCatalog(prov)) { @@ -838,6 +867,7 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (provider === "chatgpt") return; const def = OAUTH_PROVIDERS[provider]; if (!def) return; + if (provider === GOOGLE_ANTIGRAVITY_PROVIDER) migrateLegacyAntigravityStaticCatalog(config); const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider); if (namespaceCollision) throw new Error(namespaceCollision); const existing = config.providers[provider]; diff --git a/src/providers/antigravity-models.ts b/src/providers/antigravity-models.ts index 060b938325..874071b0f6 100644 --- a/src/providers/antigravity-models.ts +++ b/src/providers/antigravity-models.ts @@ -1,3 +1,5 @@ +import { isValidModelDiscoveryModelId, MODEL_DISCOVERY_MAX_MODELS } from "./model-discovery-limits"; + // Google Antigravity (Cloud Code Assist) bundled model list. // // Single source of truth: the Antigravity `:fetchAvailableModels` backend, the same one the `agy` @@ -165,7 +167,10 @@ function antigravityPositiveInteger(value: unknown): number | undefined { * command, commit-message, transcription, and standalone image-generation models; those are not * callable through the CCA agent envelope and must not be published to the Codex catalog. */ -export function parseAntigravityAvailableModels(payload: unknown): AntigravityAvailableModel[] | null { +export function parseAntigravityAvailableModels( + payload: unknown, + maxModels = MODEL_DISCOVERY_MAX_MODELS, +): AntigravityAvailableModel[] | null { const body = antigravityRecord(payload); if (!body) return null; const models = antigravityRecord(body?.models); @@ -180,7 +185,8 @@ export function parseAntigravityAvailableModels(payload: unknown): AntigravityAv const modelIds = antigravityRecord(group)?.modelIds; if (!Array.isArray(modelIds)) continue; for (const id of modelIds) { - if (typeof id === "string" && id) ids.push(id); + if (!isValidModelDiscoveryModelId(id) || ids.length >= maxModels) return null; + ids.push(id); } } } @@ -188,6 +194,7 @@ export function parseAntigravityAvailableModels(payload: unknown): AntigravityAv // image generation in the discovery response. if (Array.isArray(body.imageGenerationModelIds) && body.imageGenerationModelIds.includes("gemini-3.1-flash-image")) { + if (ids.length >= maxModels) return null; ids.push("gemini-3.1-flash-image"); } diff --git a/src/providers/model-discovery-limits.ts b/src/providers/model-discovery-limits.ts new file mode 100644 index 0000000000..7385b02a9d --- /dev/null +++ b/src/providers/model-discovery-limits.ts @@ -0,0 +1,16 @@ +/** Hard process-wide limits shared by all live model-discovery parsers. */ +export const MODEL_DISCOVERY_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; +export const MODEL_DISCOVERY_MAX_MODELS = 2_000; +export const MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH = 1_024; + +const MODEL_DISCOVERY_MODEL_ID_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; + +/** Reject model IDs that cannot safely be published as callable catalog selectors. */ +export function isValidModelDiscoveryModelId(value: unknown): value is string { + if (typeof value !== "string") return false; + const normalized = value.trim(); + return Boolean(normalized) + && normalized === value + && normalized.length <= MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH + && !MODEL_DISCOVERY_MODEL_ID_CONTROL_CHARS.test(normalized); +} diff --git a/src/providers/model-discovery.ts b/src/providers/model-discovery.ts index d2bb5bbd8a..534c0d00ed 100644 --- a/src/providers/model-discovery.ts +++ b/src/providers/model-discovery.ts @@ -1,4 +1,16 @@ import type { OcxProviderConfig } from "../types"; +import { + isValidModelDiscoveryModelId, + MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH, + MODEL_DISCOVERY_MAX_MODELS, + MODEL_DISCOVERY_MAX_RESPONSE_BYTES, +} from "./model-discovery-limits"; +export { + isValidModelDiscoveryModelId, + MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH, + MODEL_DISCOVERY_MAX_MODELS, + MODEL_DISCOVERY_MAX_RESPONSE_BYTES, +} from "./model-discovery-limits"; import { getProviderRegistryEntry, providerMatchesRegistryTransport, @@ -9,13 +21,8 @@ import { type ProviderModelDiscoverySpec, } from "./registry"; -/** Hard process-wide limits. Registry entries may lower, but never raise, these ceilings. */ -export const MODEL_DISCOVERY_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; -export const MODEL_DISCOVERY_MAX_MODELS = 2_000; -export const MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH = 1_024; const MODEL_DISCOVERY_MAX_FILTER_VALUES = 256; const MODEL_DISCOVERY_MAX_FILTER_STRING_LENGTH = 1_024; -const MODEL_DISCOVERY_MODEL_ID_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; export interface ResolvedProviderModelDiscovery { spec?: ProviderModelDiscoverySpec; @@ -343,16 +350,8 @@ export function extractProviderModelItems( return { ok: false, reason: "invalid_shape" }; } const id = (raw as { id?: unknown }).id; - if (typeof id !== "string") return { ok: false, reason: "invalid_shape" }; - const normalizedId = id.trim(); - if ( - !normalizedId - || normalizedId !== id - || normalizedId.length > MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH - || MODEL_DISCOVERY_MODEL_ID_CONTROL_CHARS.test(normalizedId) - ) { - return { ok: false, reason: "invalid_shape" }; - } + if (!isValidModelDiscoveryModelId(id)) return { ok: false, reason: "invalid_shape" }; + const normalizedId = id; const item = raw as ProviderModelsApiItem; if (!providerModelMatchesDiscoveryFilter(item, discovery.spec?.filter) || seen.has(normalizedId)) continue; seen.add(normalizedId); diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index d2a8f369c3..7ee4c98c65 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -561,7 +561,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { writeConfig({ port: 12345, defaultProvider: "custom", - googleAntigravityStaticCatalogVersion: 99, + googleAntigravityStaticCatalogVersion: 2, providers: { custom: { adapter: "openai-chat", baseUrl: "https://example.test/v1" } }, }); - const degraded = loadConfig(); - expect(degraded.googleAntigravityStaticCatalogVersion).toBeUndefined(); - expect(degraded.providers.custom.baseUrl).toBe("https://example.test/v1"); + expect(loadConfig().googleAntigravityStaticCatalogVersion).toBe(2); expect(backupNames()).toEqual([]); expect(validateConfigCandidate({ ...getDefaultConfig(), - googleAntigravityStaticCatalogVersion: 99, + googleAntigravityStaticCatalogVersion: 3, })).toMatchObject({ ok: false, error: expect.stringContaining("googleAntigravityStaticCatalogVersion"), diff --git a/tests/cursor-hardening.test.ts b/tests/cursor-hardening.test.ts index 4de7bc0aba..c7bf93b959 100644 --- a/tests/cursor-hardening.test.ts +++ b/tests/cursor-hardening.test.ts @@ -109,6 +109,44 @@ describe("Cursor live-model discovery hardening", () => { } }); + test("does not warn when a failed Cursor discovery belongs to a cleared generation", async () => { + const provider = "cursor-discovery-stale-warning"; + const warning = spyOn(console, "warn").mockImplementation(() => {}); + let release!: () => void; + const started = new Promise(resolve => { release = resolve; }); + let stream!: http2.ServerHttp2Stream; + try { + await withDiscoveryServer(candidate => { + stream = candidate; + release(); + }, async baseUrl => { + const pending = gatherRoutedModels({ + providers: { + [provider]: { + adapter: "cursor", + baseUrl, + apiKey: "test-token", + models: ["auto"], + }, + }, + }); + await started; + clearModelCache(provider); + stream.respond({ ":status": 401, "content-type": "application/proto" }); + stream.end(); + await pending; + }); + + expect(warning.mock.calls.some(args => String(args[0]).includes( + `Cursor model discovery for "${provider}" failed`, + ))).toBe(false); + expect(getProviderDiscoveryStatus(provider)).toBeUndefined(); + } finally { + warning.mockRestore(); + clearModelCache(provider); + } + }); + test("classifies non-auth HTTP failures", async () => { const result = await withDiscoveryServer(respond(503), baseUrl => fetchCursorUsableModels({ apiKey: "test-token", baseUrl })); diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index 9598dc4db4..ce164f847e 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createGoogleAdapter as createGoogleAdapterProduction } from "../src/adapters/google"; import { antigravitySessionId, isLikelyRealThoughtSignature } from "../src/adapters/google-antigravity-wire"; import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_EFFORTS, canonicalAntigravityUsageModel, parseAntigravityAvailableModels } from "../src/providers/antigravity-models"; +import { MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH, MODEL_DISCOVERY_MAX_MODELS } from "../src/providers/model-discovery"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; @@ -138,6 +139,23 @@ describe("antigravity CCA envelope", () => { ]); }); + test("rejects malformed and oversized CCA agent-model lists", () => { + const payload = (modelIds: unknown[]) => ({ + models: Object.fromEntries(modelIds.map(id => [String(id), { maxTokens: 1_048_576 }])), + agentModelSorts: [{ groups: [{ modelIds }] }], + }); + + for (const invalidId of [" ", "bad\u0000id", "x".repeat(MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH + 1)]) { + expect(parseAntigravityAvailableModels(payload([invalidId]))).toBeNull(); + } + expect(parseAntigravityAvailableModels({ + models: {}, + agentModelSorts: [{ groups: [{ + modelIds: Array.from({ length: MODEL_DISCOVERY_MAX_MODELS + 1 }, (_, index) => `model-${index}`), + }] }], + })).toBeNull(); + }); + test("throws when no project id is available", async () => { const noProj = { ...provider, project: undefined } as OcxProviderConfig; await expect(createGoogleAdapter(noProj).buildRequest(parsed())).rejects.toThrow(/project id/); diff --git a/tests/google-models-listing.test.ts b/tests/google-models-listing.test.ts index c6cf04453c..332c60e46d 100644 --- a/tests/google-models-listing.test.ts +++ b/tests/google-models-listing.test.ts @@ -163,6 +163,47 @@ describe("Antigravity live model discovery", () => { rmSync(home, { recursive: true, force: true }); } }); + + test("degrades malformed CCA agent IDs to the configured static catalog", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-antigravity-malformed-discovery-")); + process.env.OPENCODEX_HOME = home; + writeFileSync(join(home, "auth.json"), JSON.stringify({ + "google-antigravity": { + activeAccountId: "active", + accounts: [{ + id: "active", + credential: { + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 3_600_000, + projectId: "project-id", + }, + }], + }, + })); + const warning = spyOn(console, "warn").mockImplementation(() => {}); + globalThis.fetch = (async () => Response.json({ + models: { "bad\u0000model": { maxTokens: 1_048_576 } }, + agentModelSorts: [{ groups: [{ modelIds: ["bad\u0000model"] }] }], + })) as typeof fetch; + + try { + const models = await gatherRoutedModels(configWith("google-antigravity", { + adapter: "google", + authMode: "oauth", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + liveModels: true, + models: ["configured-only"], + })); + + expect(models.filter(model => model.provider === "google-antigravity").map(model => model.id)) + .toEqual(["configured-only"]); + expect(getStaleCached("google-antigravity")).toBeNull(); + } finally { + warning.mockRestore(); + rmSync(home, { recursive: true, force: true }); + } + }); }); describe("buildModelsRequest anthropic routing", () => { diff --git a/tests/model-cache.test.ts b/tests/model-cache.test.ts new file mode 100644 index 0000000000..fae7a3e45a --- /dev/null +++ b/tests/model-cache.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + captureModelCacheGeneration, + clearModelCache, + getStaleCached, + reconcileModelCacheProviders, + setCached, +} from "../src/codex/model-cache"; + +const provider = "removed-provider-generation"; + +afterEach(() => clearModelCache(provider)); + +describe("model-cache provider reconciliation", () => { + test("rejects an in-flight write for a provider removed before it has a cache entry", () => { + const captured = captureModelCacheGeneration(provider); + + expect(reconcileModelCacheProviders(new Set(), Date.now())).toBe(1); + expect(setCached(provider, [{ provider, id: "late-model" }], Date.now(), captured)).toBe(false); + expect(getStaleCached(provider)).toBeNull(); + }); +}); diff --git a/tests/oauth-provider-reconcile.test.ts b/tests/oauth-provider-reconcile.test.ts index 6b71643628..611a4e969f 100644 --- a/tests/oauth-provider-reconcile.test.ts +++ b/tests/oauth-provider-reconcile.test.ts @@ -77,7 +77,7 @@ describe("OAuth provider reconciliation", () => { expect(reconcileOAuthProviders(config)).toBe(false); }); - test("preserves an explicit Antigravity liveModels override during reconcile and re-login", () => { + test("migrates the version-1 canonical Antigravity static row to live discovery", () => { const config = { port: 10100, defaultProvider: "google-antigravity", @@ -85,19 +85,37 @@ describe("OAuth provider reconciliation", () => { providers: { "google-antigravity": { ...structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig), - liveModels: true, + liveModels: false, }, }, } satisfies OcxConfig; - expect(reconcileOAuthProviders(config)).toBe(false); + expect(reconcileOAuthProviders(config)).toBe(true); expect(config.providers["google-antigravity"].liveModels).toBe(true); + expect(config.googleAntigravityStaticCatalogVersion).toBe(2); upsertOAuthProvider(config, "google-antigravity"); expect(config.providers["google-antigravity"].liveModels).toBe(true); expect(config.providers["google-antigravity"].models).toHaveLength(6); }); + test("preserves an explicit Antigravity static opt-out without the legacy migration marker", () => { + const config = { + port: 10100, + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + ...structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig), + liveModels: false, + }, + }, + } satisfies OcxConfig; + + expect(reconcileOAuthProviders(config)).toBe(false); + upsertOAuthProvider(config, "google-antigravity"); + expect(config.providers["google-antigravity"].liveModels).toBe(false); + }); + test("preserves explicit Antigravity live discovery when authMode is omitted or non-OAuth", () => { const home = mkdtempSync(join(tmpdir(), "ocx-antigravity-authmode-reconcile-")); homes.push(home); From f5f0f432d149ded7491ea46c72b38b929cb5610c Mon Sep 17 00:00:00 2001 From: Xinwei Gao Date: Sat, 8 Aug 2026 19:32:11 +0800 Subject: [PATCH 5/6] fix(antigravity): harden live model discovery --- src/codex/catalog/provider-fetch.ts | 2 +- src/providers/antigravity-models.ts | 13 ++++++--- tests/google-antigravity-wire.test.ts | 38 +++++++++++++++++++++++++++ tests/google-models-listing.test.ts | 26 ++++++++++++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index c1fc71fb9e..5f75b0399d 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -876,7 +876,7 @@ async function fetchProviderModelsWithAuth( return observed(configured, "authoritative"); } const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing" - ? effectiveGoogleMode(name, prov) === "cloud-code-assist" + ? prov.authMode === "oauth" && effectiveGoogleMode(name, prov) === "cloud-code-assist" ? await getValidAccessTokenSnapshot(name) .then(snapshot => ({ apiKey: snapshot.accessToken, diff --git a/src/providers/antigravity-models.ts b/src/providers/antigravity-models.ts index 874071b0f6..67dcf84ffe 100644 --- a/src/providers/antigravity-models.ts +++ b/src/providers/antigravity-models.ts @@ -171,6 +171,9 @@ export function parseAntigravityAvailableModels( payload: unknown, maxModels = MODEL_DISCOVERY_MAX_MODELS, ): AntigravityAvailableModel[] | null { + const limit = Number.isSafeInteger(maxModels) && maxModels > 0 + ? Math.min(maxModels, MODEL_DISCOVERY_MAX_MODELS) + : MODEL_DISCOVERY_MAX_MODELS; const body = antigravityRecord(payload); if (!body) return null; const models = antigravityRecord(body?.models); @@ -180,12 +183,14 @@ export function parseAntigravityAvailableModels( const ids: string[] = []; for (const sort of sorts) { const groups = antigravityRecord(sort)?.groups; - if (!Array.isArray(groups)) continue; + if (!Array.isArray(groups)) return null; for (const group of groups) { const modelIds = antigravityRecord(group)?.modelIds; - if (!Array.isArray(modelIds)) continue; + if (!Array.isArray(modelIds)) return null; for (const id of modelIds) { - if (!isValidModelDiscoveryModelId(id) || ids.length >= maxModels) return null; + if (!isValidModelDiscoveryModelId(id) + || !antigravityRecord(models[id]) + || ids.length >= limit) return null; ids.push(id); } } @@ -194,7 +199,7 @@ export function parseAntigravityAvailableModels( // image generation in the discovery response. if (Array.isArray(body.imageGenerationModelIds) && body.imageGenerationModelIds.includes("gemini-3.1-flash-image")) { - if (ids.length >= maxModels) return null; + if (ids.length >= limit) return null; ids.push("gemini-3.1-flash-image"); } diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index ce164f847e..5bab83070e 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -156,6 +156,44 @@ describe("antigravity CCA envelope", () => { })).toBeNull(); }); + test("rejects malformed CCA agent-model containers and missing agent metadata", () => { + expect(parseAntigravityAvailableModels({ + models: {}, + agentModelSorts: [{}], + })).toBeNull(); + expect(parseAntigravityAvailableModels({ + models: {}, + agentModelSorts: [{ groups: {} }], + })).toBeNull(); + expect(parseAntigravityAvailableModels({ + models: {}, + agentModelSorts: [{ groups: [{ modelIds: {} }] }], + })).toBeNull(); + expect(parseAntigravityAvailableModels({ + models: {}, + agentModelSorts: [{ groups: [{ modelIds: ["agent-model"] }] }], + })).toBeNull(); + }); + + test("normalizes untrusted CCA model limits before publishing a catalog", () => { + const oversized = Array.from( + { length: MODEL_DISCOVERY_MAX_MODELS + 1 }, + (_, index) => `model-${index}`, + ); + const payload = { + models: Object.fromEntries(oversized.map(id => [id, { maxTokens: 1_048_576 }])), + agentModelSorts: [{ groups: [{ modelIds: oversized }] }], + }; + for (const limit of [Number.NaN, Infinity, MODEL_DISCOVERY_MAX_MODELS + 1]) { + expect(parseAntigravityAvailableModels(payload, limit)).toBeNull(); + } + expect(parseAntigravityAvailableModels({ + models: { "agent-model": { maxTokens: 1_048_576 } }, + agentModelSorts: [{ groups: [{ modelIds: ["agent-model"] }] }], + imageGenerationModelIds: ["gemini-3.1-flash-image"], + }, 1)).toBeNull(); + }); + test("throws when no project id is available", async () => { const noProj = { ...provider, project: undefined } as OcxProviderConfig; await expect(createGoogleAdapter(noProj).buildRequest(parsed())).rejects.toThrow(/project id/); diff --git a/tests/google-models-listing.test.ts b/tests/google-models-listing.test.ts index 332c60e46d..419cc76559 100644 --- a/tests/google-models-listing.test.ts +++ b/tests/google-models-listing.test.ts @@ -204,6 +204,32 @@ describe("Antigravity live model discovery", () => { rmSync(home, { recursive: true, force: true }); } }); + + test("uses the configured key for a custom CCA provider", async () => { + const seen: { headers: Record }[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + seen.push({ headers: (init?.headers ?? {}) as Record }); + return Response.json({ + models: { "custom-agent-model": { maxTokens: 1_048_576 } }, + agentModelSorts: [{ groups: [{ modelIds: ["custom-agent-model"] }] }], + }); + }) as typeof fetch; + + const models = await gatherRoutedModels(configWith("custom-cca", { + adapter: "google", + authMode: "key", + apiKey: "custom-cca-key", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + googleMode: "cloud-code-assist", + project: "configured-project", + liveModels: true, + })); + + expect(seen).toHaveLength(1); + expect(seen[0]?.headers.Authorization).toBe("Bearer custom-cca-key"); + expect(models.filter(model => model.provider === "custom-cca").map(model => model.id)) + .toEqual(["custom-agent-model"]); + }); }); describe("buildModelsRequest anthropic routing", () => { From 2ebdb705ccaa0bb02940a7f2d7668135eead0070 Mon Sep 17 00:00:00 2001 From: Xinwei Gao Date: Sat, 8 Aug 2026 19:58:40 +0800 Subject: [PATCH 6/6] fix(catalog): distinguish cache eviction from authority changes --- src/codex/catalog/sync.ts | 2 +- src/codex/model-cache.ts | 15 +++++++++--- tests/helpers/provider-registry-discovery.ts | 4 ++-- tests/model-cache.test.ts | 24 ++++++++++++++++++++ 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 1eeb890ff6..d9fae24c04 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -466,7 +466,7 @@ export function resetCatalogRuntimeStateForTests(): void { comboUnrestorableShadowWarnings.clear(); accountSelectorShadowCollisionWarnings.clear(); clearLastComboCatalogOmissions(); - clearModelCache(); + clearModelCache(undefined, "eviction"); clearGatherRoutedModelsInflight(); } diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts index 700537fc64..af6eb67e6c 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -42,6 +42,9 @@ export type ProviderModelDiscoveryFailure = ProviderModelDiscoveryStatus extends : never : never; +/** Whether clearing cache rows also revokes in-flight discovery authority. */ +export type ModelCacheClearReason = "authority" | "eviction"; + const cache = new Map(); let globalCacheGeneration = 0; const providerCacheGenerations = new Map(); @@ -194,15 +197,21 @@ export function setCached( } /** Drop one provider's cache (or all) so the next resolve forces a live re-fetch. */ -export function clearModelCache(provider?: string): void { +export function clearModelCache( + provider?: string, + reason: ModelCacheClearReason = "authority", +): void { + const revokesInFlightDiscovery = reason === "authority"; if (provider) { - providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1); + if (revokesInFlightDiscovery) { + providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1); + } deleteCachedProvider(provider); failureAt.delete(provider); discoveryStatus.delete(provider); liveModelCounts.delete(provider); } else { - globalCacheGeneration += 1; + if (revokesInFlightDiscovery) globalCacheGeneration += 1; cache.clear(); cacheBytes = 0; oldestCachedProvider = undefined; diff --git a/tests/helpers/provider-registry-discovery.ts b/tests/helpers/provider-registry-discovery.ts index 2d1e28e657..b98db5c686 100644 --- a/tests/helpers/provider-registry-discovery.ts +++ b/tests/helpers/provider-registry-discovery.ts @@ -16,7 +16,7 @@ export async function withRegistryDiscovery( if (!entry) throw new Error(`missing ${providerId} registry entry`); const originalDiscovery = entry.modelDiscovery; const originalPreserveCustomDestination = entry.preserveCustomDestination; - clearModelCache(providerId); + clearModelCache(providerId, "eviction"); entry.modelDiscovery = spec; if (overrides.preserveCustomDestination !== undefined) { entry.preserveCustomDestination = overrides.preserveCustomDestination; @@ -28,6 +28,6 @@ export async function withRegistryDiscovery( else entry.modelDiscovery = originalDiscovery; if (originalPreserveCustomDestination === undefined) delete entry.preserveCustomDestination; else entry.preserveCustomDestination = originalPreserveCustomDestination; - clearModelCache(providerId); + clearModelCache(providerId, "eviction"); } } diff --git a/tests/model-cache.test.ts b/tests/model-cache.test.ts index fae7a3e45a..d3b30f3720 100644 --- a/tests/model-cache.test.ts +++ b/tests/model-cache.test.ts @@ -12,6 +12,30 @@ const provider = "removed-provider-generation"; afterEach(() => clearModelCache(provider)); describe("model-cache provider reconciliation", () => { + test.each([ + ["provider", () => clearModelCache(provider, "eviction")], + ["global", () => clearModelCache(undefined, "eviction")], + ])("%s eviction keeps an in-flight discovery authorized", (_scope, evict) => { + const captured = captureModelCacheGeneration(provider); + + evict(); + + expect(setCached(provider, [{ provider, id: "late-model" }], Date.now(), captured)).toBe(true); + expect(getStaleCached(provider)).toEqual([{ provider, id: "late-model" }]); + }); + + test.each([ + ["provider", () => clearModelCache(provider)], + ["global", () => clearModelCache()], + ])("%s authority change rejects an in-flight discovery", (_scope, revokeAuthority) => { + const captured = captureModelCacheGeneration(provider); + + revokeAuthority(); + + expect(setCached(provider, [{ provider, id: "late-model" }], Date.now(), captured)).toBe(false); + expect(getStaleCached(provider)).toBeNull(); + }); + test("rejects an in-flight write for a provider removed before it has a cache entry", () => { const captured = captureModelCacheGeneration(provider);