From c5c607eb12ddbee343ed4449bce4abc39cf6ff9a Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:16:09 +0900 Subject: [PATCH 1/6] fix(providers): keep Antigravity catalog static --- .../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 | 16 ++++---- src/oauth/index.ts | 12 +++++- tests/codex-catalog.test.ts | 37 +++++++++++++++++++ tests/google-models-listing.test.ts | 7 ++-- tests/oauth-provider-reconcile.test.ts | 24 +++++++++++- tests/provider-connection-test.test.ts | 19 ++++++++++ tests/provider-registry-parity.test.ts | 4 ++ 12 files changed, 109 insertions(+), 20 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 66225e266..4f2e0b52a 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -86,7 +86,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. | +| `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. | | `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 d24368139..36785e4cd 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -81,7 +81,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 で使用。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。CCA は汎用 `/models` エンドポイントを公開しないため、管理された 6 モデルの静的カタログを使用します。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | 正規の Kimi Coding Plan プリセット(`kimi` アカウントログインと `kimi-code` API key)では、 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 34a2fbcb8..a1bcbfb93 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -81,7 +81,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로 사용합니다. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. CCA가 범용 `/models` 엔드포인트를 제공하지 않으므로 유지 관리되는 6개 모델 정적 카탈로그를 사용합니다. | | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | 정식 Kimi Coding Plan 프리셋(`kimi` 계정 로그인과 `kimi-code` API key)의 경우, opencodex는 diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 1d6fa4768..111f19d42 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -89,7 +89,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. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Используется поддерживаемый статический каталог из шести моделей, поскольку CCA не предоставляет общий эндпоинт `/models`. | | `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 87a31fd05..85d387708 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -75,7 +75,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。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。由于 CCA 不提供通用 `/models` 端点,因此使用维护中的六模型静态目录。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、HTTP/2 传输和按账号筛选的模型发现。 | 对于规范的 Kimi Coding Plan 预设(`kimi` 账号登录和 `kimi-code` API key),opencodex diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index b1d95610e..cee64750d 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -381,7 +381,6 @@ function boundedOwnedBy(value: unknown): string | undefined { export async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: number, contextCap?: number): Promise { if (prov.authMode === "forward") return []; // ChatGPT backend has no /models - const apiKey = await resolveModelsAuthToken(name, prov); const seedVertexDefault = prov.adapter === "google" && prov.googleMode === "vertex" && (prov.models?.length ?? 0) === 0 @@ -392,6 +391,13 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, provider: name, ...catalogHintsFromProviderConfig(name, prov, id, contextCap), })); + // Static catalogs never need an OAuth refresh or an upstream model request. Clear any + // discovery failure left by an older live configuration even when the account is logged out. + if (prov.liveModels === false) { + clearProviderDiscoveryStatus(name); + return configured; + } + const apiKey = await resolveModelsAuthToken(name, prov); // A configured default is a real callable selector and must remain discoverable when a // compatible provider's live /models request fails (issue #308). Keep this separate from the // explicit static list: `liveModels: false` + empty `models[]` intentionally publishes zero @@ -410,10 +416,6 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, : models ); if (prov.adapter === "cursor") { - if (prov.liveModels === false) { - clearProviderDiscoveryStatus(name); - return configured; - } if (!apiKey) return configured; // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed // variants this PLAN can use. Keep the base-model UX (the request builder appends the effort @@ -448,10 +450,6 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, // matching Cursor's !apiKey → configured degradation and fetch-failure fallback. return configured; } - if (prov.liveModels === false) { - clearProviderDiscoveryStatus(name); - return configured; - } const fresh = getFreshCached(name, ttlMs); if (fresh) return withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)); // dedups Codex's frequent /v1/models polling within the TTL if (isModelsFetchCoolingDown(name)) { diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 72e8c6d1f..426ceac7a 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -541,8 +541,9 @@ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | und * configs on the next `ocx start`, instead of only fresh installs. The live `/models` fetch stays * the primary source; this keeps the static fallback (and models-not-in-/models) current. * - * Only touches providers that are registry-managed AND still `authMode: "oauth"`, and only the - * preset fields (never apiKey/baseUrl/user toggles). Persists + returns true when anything changed. + * Only touches providers that are registry-managed AND still `authMode: "oauth"`. Preset fields + * are refreshed, while the registry's `liveModels` default is filled only when the user has no + * explicit toggle. Persists + returns true when anything changed. */ function cloneProviderField(value: unknown): unknown { if (Array.isArray(value)) return [...value]; @@ -585,6 +586,10 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean { } changed = true; } + if (prov.liveModels === undefined && preset.liveModels !== undefined) { + prov.liveModels = preset.liveModels; + changed = true; + } // Heal a defaultModel that no longer exists in the refreshed list (e.g. a deprecated snapshot). if (prov.defaultModel && preset.defaultModel && !(prov.models ?? []).includes(prov.defaultModel)) { prov.defaultModel = preset.defaultModel; @@ -651,6 +656,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 choice must survive re-login and the post-credential latest-config upsert. + if (typeof existing?.liveModels === "boolean") next.liveModels = existing.liveModels; if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) { // Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes. let storedApiKey = sanitizeApiKeyValue(existing.apiKey); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 84f85cb37..650d60676 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -25,6 +25,7 @@ import type { OcxConfig } from "../src/types"; import type { NormalizedComboConfig } from "../src/combos/types"; import { enrichProviderFromRegistry } from "../src/providers/derive"; import { handleManagementAPI } from "../src/server/management-api"; +import { OAUTH_PROVIDERS } from "../src/oauth"; const originalFetch = globalThis.fetch; @@ -1269,6 +1270,42 @@ describe("Codex catalog routed normalization", () => { } }); + test("Google Antigravity uses its static registry catalog and suppresses stale discovery (#723)", async () => { + const providerName = "google-antigravity"; + const provider = structuredClone(OAUTH_PROVIDERS[providerName].providerConfig); + const config = { + port: 10100, + defaultProvider: providerName, + providers: { [providerName]: provider }, + } as OcxConfig; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls += 1; + throw new Error("static Antigravity catalog must not call /models"); + }) as typeof fetch; + markProviderDiscoveryFailed(providerName, { reason: "http", httpStatus: 404 }); + + const models = await gatherRoutedModels(config); + const ids = models.filter(model => model.provider === providerName).map(model => model.id).sort(); + + expect(fetchCalls).toBe(0); + expect(ids).toEqual([...(provider.models ?? [])].sort()); + expect(ids).toHaveLength(6); + expect(getProviderDiscoveryStatus(providerName)).toBeUndefined(); + + markProviderDiscoveryFailed(providerName, { reason: "http", httpStatus: 404 }); + const url = new URL("http://127.0.0.1/api/providers"); + const response = await handleManagementAPI(new Request(url), url, config); + const rows = await response!.json() as Array>; + const row = rows.find(item => item.name === providerName); + expect(row).toMatchObject({ + name: providerName, + liveModels: false, + models: provider.models, + }); + expect(row).not.toHaveProperty("discovery"); + }); + test("failed discovery falls back to defaultModel when no static models are configured (#308)", async () => { clearModelCache("anthropic-compatible-default"); const originalFetch = globalThis.fetch; diff --git a/tests/google-models-listing.test.ts b/tests/google-models-listing.test.ts index 1c0177fc3..0a26fc3c7 100644 --- a/tests/google-models-listing.test.ts +++ b/tests/google-models-listing.test.ts @@ -38,9 +38,10 @@ describe("buildModelsRequest google routing", () => { expect(headers["x-goog-api-key"]).toBe("gk-123"); }); - test("google-antigravity (oauth) keeps Authorization: Bearer via registry backfill", () => { - // A saved config may omit googleMode — the registry entry (cloud-code-assist) must win. - const prov = { adapter: "google", authMode: "oauth", baseUrl: "https://daily-cloudcode-pa.googleapis.com" } as OcxProviderConfig; + 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. + 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"); expect(headers["Authorization"]).toBe("Bearer oauth-token"); diff --git a/tests/oauth-provider-reconcile.test.ts b/tests/oauth-provider-reconcile.test.ts index 246755268..ec903367a 100644 --- a/tests/oauth-provider-reconcile.test.ts +++ b/tests/oauth-provider-reconcile.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "../src/config"; -import { reconcileOAuthProviders } from "../src/oauth"; +import { OAUTH_PROVIDERS, reconcileOAuthProviders, upsertOAuthProvider } from "../src/oauth"; import { getCredential, saveCredential } from "../src/oauth/store"; import type { OcxConfig } from "../src/types"; @@ -61,6 +61,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(provider.project).toBe("config-project-sentinel"); expect(provider.note).toBe("user-owned-note"); expect(getCredential("google-antigravity")).toMatchObject({ @@ -71,6 +72,27 @@ 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(reconcileOAuthProviders(config)).toBe(false); }); + + test("preserves an explicit Antigravity liveModels override during reconcile and re-login", () => { + const config = { + port: 10100, + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + ...structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig), + liveModels: true, + }, + }, + } satisfies OcxConfig; + + expect(reconcileOAuthProviders(config)).toBe(false); + expect(config.providers["google-antigravity"].liveModels).toBe(true); + + upsertOAuthProvider(config, "google-antigravity"); + expect(config.providers["google-antigravity"].liveModels).toBe(true); + expect(config.providers["google-antigravity"].models).toHaveLength(6); + }); }); diff --git a/tests/provider-connection-test.test.ts b/tests/provider-connection-test.test.ts index 74bda3dc4..762c0d5f9 100644 --- a/tests/provider-connection-test.test.ts +++ b/tests/provider-connection-test.test.ts @@ -4,6 +4,8 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { handleManagementAPI } from "../src/server/management-api"; import { saveConfig } from "../src/config"; +import { OAUTH_PROVIDERS } from "../src/oauth"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; import type { OcxConfig } from "../src/types"; import { withRegistryDiscovery } from "./helpers/provider-registry-discovery"; @@ -96,6 +98,23 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { expect(String(body.error)).toContain("static catalog only"); }); + test("Google Antigravity uses the static-only probe without network access (#723)", async () => { + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + throw new Error("static Antigravity catalog must not probe upstream"); + }) as typeof fetch; + const config = baseConfig({ + "google-antigravity": structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig), + }); + + const { body } = await probe(config, "google-antigravity"); + + expect(body.ok).toBe(false); + expect(String(body.error)).toContain("static catalog only"); + expect(fetches).toBe(0); + }); + 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 9d7d91d40..329843168 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -613,6 +613,10 @@ describe("provider registry parity", () => { expect(OAUTH_PROVIDERS.xai.providerConfig.modelContextWindows?.["grok-4.5"]).toBe(500_000); 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(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 954a6609ceee9937a468cfee43ba11ad9bd69adf Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:30:08 +0900 Subject: [PATCH 2/6] fix: migrate legacy Antigravity discovery settings --- .../provider-workspace/ProviderSettings.tsx | 5 +- ...r-settings-live-models-provenance.test.tsx | 117 ++++++++++++++++++ src/config.ts | 15 ++- src/oauth/index.ts | 32 ++++- src/types.ts | 2 + tests/config.test.ts | 31 +++++ tests/oauth-provider-reconcile.test.ts | 55 ++++++++ 7 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 gui/tests/provider-settings-live-models-provenance.test.tsx diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index c43776467..5436651f5 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -135,7 +135,10 @@ export default function ProviderSettings({ setSaving(true); setMsg(null); try { - const patch: ProviderUpdatePatch = { adapter: adapter.trim(), baseUrl: nextBaseUrl, defaultModel: defaultModel.trim(), authMode, note: note.trim(), allowPrivateNetwork, liveModels }; + const patch: ProviderUpdatePatch = { adapter: adapter.trim(), baseUrl: nextBaseUrl, defaultModel: defaultModel.trim(), authMode, note: note.trim(), allowPrivateNetwork }; + // Keep omitted legacy values omitted unless the user actually changes this toggle. + // Otherwise an unrelated settings save manufactures `liveModels: true` provenance. + if (liveModels !== (item.liveModels !== false)) patch.liveModels = liveModels; if (supportsApiKeyTransport) patch.apiKeyTransport = apiKeyTransport; else if (item.apiKeyTransport !== undefined) patch.apiKeyTransport = ""; const res = await onUpdateProvider(item.name, patch); diff --git a/gui/tests/provider-settings-live-models-provenance.test.tsx b/gui/tests/provider-settings-live-models-provenance.test.tsx new file mode 100644 index 000000000..097780177 --- /dev/null +++ b/gui/tests/provider-settings-live-models-provenance.test.tsx @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import ProviderSettings from "../src/components/provider-workspace/ProviderSettings"; +import type { ProviderUpdatePatch } from "../src/components/provider-workspace/types"; +import { LanguageProvider } from "../src/i18n/provider"; +import type { WorkspaceItem } from "../src/provider-workspace/catalog"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#providers/workspace" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +function provider(liveModels?: boolean): WorkspaceItem { + return { + name: "custom-provider", + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + authMode: "key", + note: "before", + ...(liveModels === undefined ? {} : { liveModels }), + } as WorkspaceItem; +} + +async function mountSettings(item: WorkspaceItem): Promise<{ + root: Root; + container: HTMLElement; + patches: ProviderUpdatePatch[]; +}> { + const patches: ProviderUpdatePatch[] = []; + const container = document.createElement("div"); + document.body.append(container); + const { createRoot } = await import("react-dom/client"); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render( + + { + patches.push(patch); + return { ok: true }; + }} + /> + , + ); + }); + return { root, container, patches }; +} + +async function save(container: HTMLElement): Promise { + const button = container.querySelector(".pwi-settings-sticky-bar .btn-primary"); + expect(button).toBeTruthy(); + await act(async () => { + button!.click(); + await Promise.resolve(); + }); +} + +test("an unrelated settings save does not materialize an omitted liveModels value", async () => { + const { root, container, patches } = await mountSettings(provider()); + const note = container.querySelector(".pwi-settings-textarea")!; + + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLTextAreaElement.prototype, "value")! + .set!.call(note, "after"); + note.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await save(container); + + expect(patches).toHaveLength(1); + expect(Object.hasOwn(patches[0]!, "liveModels")).toBe(false); + await act(async () => { root.unmount(); }); +}); + +test("changing an omitted effective true to false sends an explicit liveModels choice", async () => { + const { root, container, patches } = await mountSettings(provider()); + const toggles = container.querySelectorAll('input[type="checkbox"]'); + + await act(async () => { toggles[1]!.click(); }); + await save(container); + + expect(patches[0]?.liveModels).toBe(false); + await act(async () => { root.unmount(); }); +}); + +test("changing an explicit false to true sends an explicit liveModels choice", async () => { + const { root, container, patches } = await mountSettings(provider(false)); + const toggles = container.querySelectorAll('input[type="checkbox"]'); + + await act(async () => { toggles[1]!.click(); }); + await save(container); + + expect(patches[0]?.liveModels).toBe(true); + await act(async () => { root.unmount(); }); +}); diff --git a/src/config.ts b/src/config.ts index 90265ddd5..974f088a8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -678,6 +678,9 @@ 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), providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), @@ -1248,9 +1251,19 @@ function claudeSubagentEffortError(value: unknown): string | null { return `schema_invalid: claudeCode.subagentEffort: must be one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`; } +function googleAntigravityStaticCatalogVersionError(value: unknown): string | null { + 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"; +} + /** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */ export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { - const boundaryError = blankHostnameError(value) ?? claudeSubagentEffortError(value); + const boundaryError = blankHostnameError(value) + ?? claudeSubagentEffortError(value) + ?? googleAntigravityStaticCatalogVersionError(value); if (boundaryError) return { ok: false, error: boundaryError }; const result = configSchema.safeParse(value); if (result.success) return { ok: true, config: result.data as OcxConfig }; diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 426ceac7a..8aadca0b5 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -571,10 +571,22 @@ const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [ "preserveReasoningContentModels", ]; +const GOOGLE_ANTIGRAVITY_PROVIDER = "google-antigravity"; +const GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION = 1 as const; + 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]; + // Normalize the canonical row before the OAuth-only reconciliation guard. Legacy GUI + // saves can carry an omitted or non-OAuth authMode, and stamping the migration marker + // while skipping such a row would make its materialized `true` look user-authored later. + if (name === GOOGLE_ANTIGRAVITY_PROVIDER && migrateAntigravityStaticCatalog && prov.liveModels !== false) { + prov.liveModels = false; + changed = true; + } if (!def || prov.authMode !== "oauth") continue; const preset = def.providerConfig; for (const field of OAUTH_RECONCILE_FIELDS) { @@ -586,6 +598,9 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean { } changed = true; } + // Before this marker existed, the GUI materialized an omitted `liveModels` as `true` + // on any settings save. The pre-guard normalization above resets that legacy state + // once; choices made after this migration are preserved by the version boundary. if (prov.liveModels === undefined && preset.liveModels !== undefined) { prov.liveModels = preset.liveModels; changed = true; @@ -596,6 +611,10 @@ 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; } @@ -657,8 +676,14 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { 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 choice must survive re-login and the post-credential latest-config upsert. - if (typeof existing?.liveModels === "boolean") next.liveModels = existing.liveModels; + // explicit post-migration choice must survive re-login and the latest-config upsert. A + // pre-marker Antigravity `true` may have been materialized by the old GUI, so it is reset + // once instead of being misclassified as user provenance. + const preserveExistingLiveModels = provider !== GOOGLE_ANTIGRAVITY_PROVIDER + || config.googleAntigravityStaticCatalogVersion === GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION; + if (preserveExistingLiveModels && typeof existing?.liveModels === "boolean") { + next.liveModels = existing.liveModels; + } if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) { // Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes. let storedApiKey = sanitizeApiKeyValue(existing.apiKey); @@ -682,6 +707,9 @@ 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/types.ts b/src/types.ts index d950eff1b..3c6f0d709 100644 --- a/src/types.ts +++ b/src/types.ts @@ -522,6 +522,8 @@ export interface OcxConfig { defaultProvider: string; /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */ openaiProviderTierVersion?: 1 | 2; + /** One-time migration marker for Antigravity's static catalog default. */ + googleAntigravityStaticCatalogVersion?: 1; /** Claude Code inbound + launcher settings. */ claudeCode?: OcxClaudeCodeConfig; /** diff --git a/tests/config.test.ts b/tests/config.test.ts index 1a8f5930a..7f1a574a1 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -120,6 +120,37 @@ describe("opencodex config defaults", () => { expect(codexAutoStartEnabled({})).toBe(true); }); + test("Antigravity static-catalog migration marker is schema-safe but not default-injected", () => { + expect(getDefaultConfig().googleAntigravityStaticCatalogVersion).toBeUndefined(); + + writeConfig({ + port: 12345, + defaultProvider: "custom", + googleAntigravityStaticCatalogVersion: 1, + providers: { custom: { adapter: "openai-chat", baseUrl: "https://example.test/v1" } }, + }); + expect(loadConfig().googleAntigravityStaticCatalogVersion).toBe(1); + + writeConfig({ + port: 12345, + defaultProvider: "custom", + googleAntigravityStaticCatalogVersion: 99, + 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(backupNames()).toEqual([]); + + expect(validateConfigCandidate({ + ...getDefaultConfig(), + googleAntigravityStaticCatalogVersion: 99, + })).toMatchObject({ + ok: false, + error: expect.stringContaining("googleAntigravityStaticCatalogVersion"), + }); + }); + test("Codex autostart can be disabled explicitly", () => { expect(codexAutoStartEnabled({ codexAutoStart: false })).toBe(false); expect(codexAutoStartEnabled({ codexAutoStart: true })).toBe(true); diff --git a/tests/oauth-provider-reconcile.test.ts b/tests/oauth-provider-reconcile.test.ts index ec903367a..f6aa98c70 100644 --- a/tests/oauth-provider-reconcile.test.ts +++ b/tests/oauth-provider-reconcile.test.ts @@ -41,6 +41,8 @@ describe("OAuth provider reconciliation", () => { modelContextWindows: { "gemini-3.5-flash-low": 1_048_576 }, project: "config-project-sentinel", note: "user-owned-note", + // Older Provider Settings saves materialized this effective default. + liveModels: true, }, }, } satisfies OcxConfig; @@ -62,6 +64,7 @@ describe("OAuth provider reconciliation", () => { 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.project).toBe("config-project-sentinel"); expect(provider.note).toBe("user-owned-note"); expect(getCredential("google-antigravity")).toMatchObject({ @@ -73,6 +76,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(reconcileOAuthProviders(config)).toBe(false); }); @@ -80,6 +84,7 @@ describe("OAuth provider reconciliation", () => { const config = { port: 10100, defaultProvider: "google-antigravity", + googleAntigravityStaticCatalogVersion: 1, providers: { "google-antigravity": { ...structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig), @@ -95,4 +100,54 @@ describe("OAuth provider reconciliation", () => { expect(config.providers["google-antigravity"].liveModels).toBe(true); expect(config.providers["google-antigravity"].models).toHaveLength(6); }); + + test("normalizes pre-marker Antigravity rows even when authMode is omitted or non-OAuth", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-antigravity-authmode-reconcile-")); + homes.push(home); + process.env.OPENCODEX_HOME = home; + + for (const authMode of [undefined, "key"] as const) { + const provider = { + ...structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig), + liveModels: true, + }; + if (authMode === undefined) delete provider.authMode; + else provider.authMode = authMode; + const config = { + port: 10100, + defaultProvider: "google-antigravity", + providers: { "google-antigravity": provider }, + } satisfies OcxConfig; + + expect(reconcileOAuthProviders(config)).toBe(true); + expect(config.googleAntigravityStaticCatalogVersion).toBe(1); + expect(config.providers["google-antigravity"].liveModels).toBe(false); + } + }); + + test("seeds the static catalog during pre-marker re-login, then preserves later overrides", () => { + const config = { + port: 10100, + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + ...structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig), + liveModels: true, + }, + }, + } satisfies OcxConfig; + + upsertOAuthProvider(config, "google-antigravity"); + expect(config.googleAntigravityStaticCatalogVersion).toBe(1); + expect(config.providers["google-antigravity"].liveModels).toBe(false); + + config.providers["google-antigravity"].liveModels = true; + config.providers["google-antigravity"].authMode = "key"; + upsertOAuthProvider(config, "google-antigravity"); + expect(config.providers["google-antigravity"].liveModels).toBe(true); + + config.providers["google-antigravity"].authMode = undefined; + upsertOAuthProvider(config, "google-antigravity"); + expect(config.providers["google-antigravity"].liveModels).toBe(true); + }); }); From ffb6c9a0b2e775bf29c110ba79b408576896e669 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:39:02 +0900 Subject: [PATCH 3/6] fix: separate static and live catalog flights --- src/codex/catalog/provider-fetch.ts | 4 ++- ...gather-routed-models-single-flight.test.ts | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index cee64750d..c94f884ca 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -82,7 +82,9 @@ function stableJson(value: unknown): string { function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Record { return { n: name, - live: prov.liveModels !== false, + // Preserve the persisted tri-state. Registry enrichment may turn an omitted value into + // `false` while an explicit `true` stays live, so those callers must not share a flight. + live: prov.liveModels ?? null, base: prov.baseUrl ?? "", adapter: prov.adapter ?? "", models: [...(prov.models ?? [])].sort(), diff --git a/tests/gather-routed-models-single-flight.test.ts b/tests/gather-routed-models-single-flight.test.ts index d2a7ff501..891119b1a 100644 --- a/tests/gather-routed-models-single-flight.test.ts +++ b/tests/gather-routed-models-single-flight.test.ts @@ -163,6 +163,40 @@ describe("gatherRoutedModels single-flight", () => { expect(a2).toEqual(a1); }); + test("an omitted registry-static setting never shares a flight with explicit live discovery", async () => { + let fetchCount = 0; + globalThis.fetch = (async () => { + fetchCount += 1; + return new Response(JSON.stringify({ data: [{ id: "live-only" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const config = (liveModels?: true): OcxConfig => ({ + port: 10100, + defaultProvider: "alibaba-token-plan", + providers: { + "alibaba-token-plan": { + adapter: "openai-chat", + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + apiKey: "test-key", + models: ["configured-static"], + ...(liveModels === undefined ? {} : { liveModels }), + }, + }, + }); + + const [registryStatic, explicitLive] = await Promise.all([ + gatherRoutedModels(config()), + gatherRoutedModels(config(true)), + ]); + + expect(fetchCount).toBe(1); + expect(registryStatic.map(model => model.id)).toEqual(["configured-static"]); + expect(explicitLive.map(model => model.id)).toEqual(["live-only"]); + }); + test("concurrent distinct keys keep flight-local combo omissions", async () => { globalThis.fetch = (async () => new Response(JSON.stringify({ data: [{ id: "m1" }] }), { From efd4382aeb8f5376f17b222d49109629c250ea22 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:45:41 +0900 Subject: [PATCH 4/6] docs: clarify legacy discovery migration --- src/oauth/index.ts | 26 +++++++++++++++----------- tests/oauth-provider-reconcile.test.ts | 7 ++++--- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 8aadca0b5..162953590 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -542,8 +542,10 @@ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | und * the primary source; this keeps the static fallback (and models-not-in-/models) current. * * Only touches providers that are registry-managed AND still `authMode: "oauth"`. Preset fields - * are refreshed, while the registry's `liveModels` default is filled only when the user has no - * explicit toggle. Persists + returns true when anything changed. + * 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. */ function cloneProviderField(value: unknown): unknown { if (Array.isArray(value)) return [...value]; @@ -580,9 +582,11 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean { config.googleAntigravityStaticCatalogVersion !== GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION; for (const [name, prov] of Object.entries(config.providers)) { const def = OAUTH_PROVIDERS[name]; - // Normalize the canonical row before the OAuth-only reconciliation guard. Legacy GUI - // saves can carry an omitted or non-OAuth authMode, and stamping the migration marker - // while skipping such a row would make its materialized `true` look user-authored later. + // 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; @@ -598,9 +602,9 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean { } changed = true; } - // Before this marker existed, the GUI materialized an omitted `liveModels` as `true` - // on any settings save. The pre-guard normalization above resets that legacy state - // once; choices made after this migration are preserved by the version boundary. + // 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; @@ -676,9 +680,9 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { 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. A - // pre-marker Antigravity `true` may have been materialized by the old GUI, so it is reset - // once instead of being misclassified as user provenance. + // 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") { diff --git a/tests/oauth-provider-reconcile.test.ts b/tests/oauth-provider-reconcile.test.ts index f6aa98c70..adea8564b 100644 --- a/tests/oauth-provider-reconcile.test.ts +++ b/tests/oauth-provider-reconcile.test.ts @@ -41,7 +41,8 @@ describe("OAuth provider reconciliation", () => { modelContextWindows: { "gemini-3.5-flash-low": 1_048_576 }, project: "config-project-sentinel", note: "user-owned-note", - // Older Provider Settings saves materialized this effective default. + // This is deliberately ambiguous: old Provider Settings saves and manual edits + // persisted the same value, so the versioned migration normalizes both once. liveModels: true, }, }, @@ -101,7 +102,7 @@ describe("OAuth provider reconciliation", () => { expect(config.providers["google-antigravity"].models).toHaveLength(6); }); - test("normalizes pre-marker Antigravity rows even when authMode is omitted or non-OAuth", () => { + test("normalizes ambiguous pre-marker Antigravity rows even when authMode is omitted or non-OAuth", () => { const home = mkdtempSync(join(tmpdir(), "ocx-antigravity-authmode-reconcile-")); homes.push(home); process.env.OPENCODEX_HOME = home; @@ -125,7 +126,7 @@ describe("OAuth provider reconciliation", () => { } }); - test("seeds the static catalog during pre-marker re-login, then preserves later overrides", () => { + test("normalizes ambiguous pre-marker true during re-login, then preserves later overrides", () => { const config = { port: 10100, defaultProvider: "google-antigravity", From fa6b3ef27943c07103ef74b777bfa85ce6b4c28c Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:02:23 +0900 Subject: [PATCH 5/6] fix(providers): render static connection checks neutrally --- .../provider-workspace/ProviderDetails.tsx | 10 ++ .../provider-workspace/ProviderOverview.tsx | 116 ++++++++++++++++ gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + .../provider-overview-notes-save.test.tsx | 131 +++++++++++++++++- src/cli/provider-runtime.ts | 7 + src/server/management/provider-routes.ts | 5 +- tests/cli-headless-parity.test.ts | 12 ++ tests/provider-connection-test.test.ts | 10 +- 13 files changed, 289 insertions(+), 8 deletions(-) diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index e825dcee6..e0e866f80 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -96,6 +96,14 @@ export default function ProviderDetails({ const free = useMemo(() => isFreeProvider(item), [item]); const local = useMemo(() => isLocalProvider(item), [item]); const authSurface = useMemo(() => providerAuthSurface(item), [item]); + const connectionIdentity = JSON.stringify([ + codexController?.activeId ?? "", + accounts?.find(account => account.active)?.id ?? "", + keys?.find(entry => entry.active)?.id ?? "", + oauth?.loggedIn === undefined ? "" : String(oauth.loggedIn), + oauth?.needsReauth === undefined ? "" : String(oauth.needsReauth), + oauthEmail ?? "", + ]); const tabs = useMemo<{ id: Tab; label: string }[]>(() => [ { id: "overview", label: t("pws.tab.overview") }, { id: "models", label: t("pws.tab.models") }, @@ -223,6 +231,8 @@ export default function ProviderDetails({ /> ) : undefined} item={item} + apiBase={apiBase} + connectionIdentity={connectionIdentity} usageTotals={usageTotals} quotaReport={quotaReport} oauthEmail={oauthEmail} diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index ad35ac25d..5e6d998c9 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -3,6 +3,7 @@ * (STATS + Notes). Phase 030 of workspace design parity. */ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { readJsonOrThrow } from "../../fetch-json"; import { useT, useI18n } from "../../i18n/shared"; import { IconAlert, IconCheck } from "../../icons"; import { binProviderStatus, type WorkspaceItem } from "../../provider-workspace/catalog"; @@ -12,8 +13,24 @@ import type { ProviderUsageTotals } from "./types"; import { authModeLabel } from "./ProviderRail"; import type { ProviderUpdatePatch } from "./types"; +type ConnectionTestResult = { + applicable?: boolean; + ok?: boolean; + latencyMs?: number; + reason?: string; + message?: string; + error?: string; +}; + +type ConnectionTestState = { + key: string; + testing: boolean; + result: ConnectionTestResult | null; +}; + export default function ProviderOverview({ item, usageTotals, quotaReport, oauthEmail, + apiBase, connectionIdentity, onEditSettings, onViewUsage, onUpdateProvider, onReauthenticate, onCancelLogin, reauthBusy = false, accountPanel, @@ -22,6 +39,9 @@ export default function ProviderOverview({ usageTotals?: ProviderUsageTotals; quotaReport?: ProviderQuotaReportView; oauthEmail?: string; + apiBase?: string; + /** Opaque active credential identity used only to invalidate stale probe results. */ + connectionIdentity?: string; onEditSettings?: () => void; onViewUsage?: () => void; onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise<{ ok: boolean; error?: string }>; @@ -48,6 +68,81 @@ export default function ProviderOverview({ const requests = usageTotals?.requests; const tokens = usageTotals?.totalTokens; const quota = accountQuotaFromReport(quotaReport); + const connectionProbeKey = JSON.stringify([ + apiBase ?? null, + item.name, + item.adapter, + item.baseUrl, + item.authMode ?? null, + item.apiKeyTransport ?? null, + item.liveModels ?? null, + item.disabled === true, + item.hasApiKey === true, + item.hasHeaders === true, + item.allowPrivateNetwork === true, + item.keyOptional === true, + item.activeNeedsReauth === true, + connectionIdentity ?? null, + ]); + const [connectionTest, setConnectionTest] = useState(null); + const connectionAbortRef = useRef<{ key: string; controller: AbortController } | null>(null); + const testingConnection = connectionTest?.key === connectionProbeKey && connectionTest.testing; + const connectionResult = connectionTest?.key === connectionProbeKey ? connectionTest.result : null; + + useEffect(() => { + return () => { + if (connectionAbortRef.current?.key === connectionProbeKey) { + connectionAbortRef.current.controller.abort(); + connectionAbortRef.current = null; + } + }; + }, [connectionProbeKey]); + + const testConnection = useCallback(async () => { + if (!apiBase) return; + connectionAbortRef.current?.controller.abort(); + const controller = new AbortController(); + connectionAbortRef.current = { key: connectionProbeKey, controller }; + setConnectionTest({ key: connectionProbeKey, testing: true, result: null }); + try { + const response = await fetch(`${apiBase}/api/providers/test?name=${encodeURIComponent(item.name)}`, { + method: "POST", + signal: controller.signal, + }); + const result = await readJsonOrThrow(response, t("pws.connectionFailed")); + if (!result) throw new Error(t("pws.connectionFailed")); + if (!controller.signal.aborted) { + setConnectionTest({ key: connectionProbeKey, testing: false, result }); + } + } catch (error) { + if (!controller.signal.aborted) { + setConnectionTest({ + key: connectionProbeKey, + testing: false, + result: { + applicable: true, + ok: false, + error: error instanceof Error ? error.message : t("pws.connectionFailed"), + }, + }); + } + } finally { + if (connectionAbortRef.current?.controller === controller) { + connectionAbortRef.current = null; + } + } + }, [apiBase, connectionProbeKey, item.name, t]); + + const connectionState = connectionResult?.applicable === false + ? "not-applicable" + : connectionResult?.ok === true + ? "ok" + : "failed"; + const connectionText = connectionResult?.applicable === false + ? t("pws.connectionNotApplicable") + : connectionResult?.ok === true + ? (connectionResult.message || t("pws.connectionOk")) + : (connectionResult?.error || t("pws.connectionFailed")); return (
@@ -82,6 +177,27 @@ export default function ProviderOverview({
)} + {apiBase && ( +
+ + {connectionResult && ( + + {connectionText} + + )} +
+ )} {onEditSettings && (