From 52a4f2ccdf64b74e5ba9d0e233974177e69eeeba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9A=B0=EC=B2=A0?= Date: Fri, 16 Oct 2026 09:57:42 +0900 Subject: [PATCH 1/7] fix(codex): include Ultra Fast in live model discovery (carry #7115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carried from #7115. Co-authored-by: 정우철 --- .../content/docs/guides/codex-app-models.md | 16 ++- .../docs/ko/guides/codex-app-models.md | 18 ++- scripts/test-layout/layout.json | 2 +- src/codex/catalog/build-entries.ts | 10 +- src/codex/catalog/configured-tiers.ts | 2 +- structure/catalog.md | 2 +- .../ultrafast-live-catalog.test.ts | 135 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 8 files changed, 173 insertions(+), 14 deletions(-) create mode 100644 tests/codex-integration/ultrafast-live-catalog.test.ts diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index cd39beb0418..70fc8ccf02d 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -341,10 +341,18 @@ Desktop if it still has the old list, select the routed model, then choose **Ult in its speed menu. Standard remains the default. Only an eligible `service-tier` wire with an explicit `ultrafast → ultrafast` mapping adds -this choice. Ordinary Fast support, a GPT-like name, native/account rows and combo aliases -do not enable it. Removing the mapping or disabling that model's service-tier support -removes the generated choice from both the catalog and cache on the next successful sync. -The separate `ultraFastTier` setting still only retains hand-supplied catalog tiers. +this choice to routed rows; ordinary Fast support or a GPT-like name is insufficient. +That routed mapping does not apply to native/account rows or combo aliases. Removing the +mapping or disabling that model's service-tier support removes the generated routed choice +from both the catalog and cache on the next successful sync. +The active declaration also controls the Ultra Fast choice when Codex loads its model list over HTTP. + +For routed rows, `ultraFastTier` retains independently supplied Ultra Fast tiers from the +same exact persisted row; generated routed tiers are not treated as operator-supplied. +For bare native `gpt-6-astra` / `gpt-6.1-sol` rows and their trusted account-bound clones, +enabling `ultraFastTier` also generates a missing Ultrafast choice without changing the +native default tier. Disabling it withdraws only native tier metadata tracked as generated; +independently supplied or edited native tier descriptors are preserved. [OpenAI's API guide](https://developers.openai.com/api/docs/guides/ultrafast-mode) documents Ultrafast for GPT-6 Astra and GPT-6.1 Sol. API access and diff --git a/docs-site/src/content/docs/ko/guides/codex-app-models.md b/docs-site/src/content/docs/ko/guides/codex-app-models.md index 22a2035c24f..899ffc19752 100644 --- a/docs-site/src/content/docs/ko/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ko/guides/codex-app-models.md @@ -198,11 +198,19 @@ opencodex의 Fast 모드를 **Auto**로 유지하세요(`fastMode` 생략). Fast 목록이 남아 있다면 Codex 데스크톱을 다시 시작한 뒤 라우팅 모델의 속도 메뉴에서 **Ultra Fast**를 선택하세요. 기본 속도는 Standard로 유지됩니다. -사용 가능한 `service-tier` 전송 경로와 명시적인 `ultrafast → ultrafast` 매핑이 있어야 합니다. -일반 Fast 지원, GPT와 비슷한 이름, 네이티브·계정별 항목, combo 별칭만으로는 추가되지 않습니다. -매핑을 제거하거나 모델의 service-tier 지원을 끄면 다음 동기화 성공 시 카탈로그와 캐시에서 -자동 생성된 선택지도 제거됩니다. 별도의 `ultraFastTier` 설정은 기존처럼 사용자가 직접 넣은 -카탈로그 tier를 보존하는 용도입니다. +이 선택지를 라우팅 항목에 자동 생성하려면 사용 가능한 `service-tier` 전송 경로와 명시적인 +`ultrafast → ultrafast` 매핑이 있어야 합니다. 일반 Fast 지원이나 GPT와 비슷한 이름만으로는 +추가되지 않습니다. 이 라우팅 매핑은 네이티브·계정별 네이티브 항목이나 combo 별칭에는 +적용되지 않습니다. 매핑을 제거하거나 모델의 service-tier 지원을 끄면 다음 동기화 성공 시 +카탈로그와 캐시에서 자동 생성된 라우팅 선택지도 제거됩니다. +Codex가 HTTP로 모델 목록을 불러올 때도 현재 적용 중인 설정의 동일한 선언이 Ultra Fast 선택지를 결정합니다. + +라우팅 항목에서는 `ultraFastTier`가 `provider/model` 식별자가 정확히 일치하는 저장된 항목에 사용자가 +별도로 넣은 Ultra Fast tier를 보존합니다. 자동 생성된 라우팅 tier는 사용자 제공 tier로 +취급하지 않습니다. bare native `gpt-6-astra` / `gpt-6.1-sol` 항목과 신뢰된 계정별 복제 +항목에서는 `ultraFastTier`를 켜면 네이티브 기본 tier를 바꾸지 않고 누락된 Ultrafast 선택지도 +생성합니다. 끄면 자동 생성한 것으로 추적되는 네이티브 tier 메타데이터만 제거하며, +사용자가 별도로 넣거나 수정한 네이티브 tier 항목은 보존합니다. [OpenAI API 문서](https://developers.openai.com/api/docs/guides/ultrafast-mode)는 GPT-6 Astra와 GPT-6.1 Sol의 Ultrafast 지원을 안내합니다. API 사용 권한과 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9925e111815..164d4ecb134 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1777,7 +1777,7 @@ "tray-proxy-deadline.test.ts": "windows", "tray-proxy.test.ts": "windows", "types-barrel-identity.test.ts": "config", - "ultrafast-tier-honesty.test.ts": "codex-integration", "configured-ultrafast-picker.test.ts": "codex-integration", "native-ultrafast-picker.test.ts": "codex-integration", + "ultrafast-tier-honesty.test.ts": "codex-integration", "configured-ultrafast-picker.test.ts": "codex-integration", "native-ultrafast-picker.test.ts": "codex-integration", "ultrafast-live-catalog.test.ts": "codex-integration", "umans-provider.test.ts": "providers", "uninstall.test.ts": "cli", "update-async-routes.test.ts": "server", diff --git a/src/codex/catalog/build-entries.ts b/src/codex/catalog/build-entries.ts index 8bf567d31ce..61ea74f1bec 100644 --- a/src/codex/catalog/build-entries.ts +++ b/src/codex/catalog/build-entries.ts @@ -48,7 +48,7 @@ import { NATIVE_RESERVE_MODEL } from "./native-models"; import { isReserveCatalogProjection, type ReserveCatalogProjection } from "./reserve"; import { deriveEntry, finishUpstreamNativeEntry, isExactComboCatalogEntry } from "./derive-entry"; import { PICKER_ORDER_PRIORITY_BASE, SPAWN_PRIORITY_FIELD } from "./subagent-roster"; -import { applyConfiguredUltraFast } from "./configured-tiers"; +import { applyConfiguredUltraFast, configuredUltraFastSlugs } from "./configured-tiers"; import { applyNativeUltraFastTier } from "./native-ultrafast"; export interface ObservedCatalogEntryBuildInput { @@ -111,6 +111,14 @@ export function buildCatalogEntries( accountNativeSlugs, accountNativeSlugsBySelector, }); + // Live discovery does not run the disk merge; use the same captured declarations and row fences. + const configured = configuredUltraFastSlugs(goModels); + for (const entry of entries) { + if (typeof entry.slug !== "string" || !entry.slug.includes("/") + || isNativeAliasCatalogEntry(entry) || isExactComboCatalogEntry(entry, exactComboSlugs) + || trustedAccountBoundNativeCatalogSlug(entry) !== undefined) continue; + applyConfiguredUltraFast(entry, undefined, configured.has(entry.slug), false); + } applyFullModelPickerOrder(entries, modelPickerOrder); return entries; } diff --git a/src/codex/catalog/configured-tiers.ts b/src/codex/catalog/configured-tiers.ts index f46489e9fa2..7eb723550b2 100644 --- a/src/codex/catalog/configured-tiers.ts +++ b/src/codex/catalog/configured-tiers.ts @@ -12,7 +12,7 @@ export function supportsConfiguredUltraFast(policy: ResolvedFastPolicy | undefin && policy.fastWire.canonicalToWire.ultrafast === "ultrafast"; } -/** Carry captured capability to the pure merge without treating persisted metadata as authority. */ +/** Carry captured capability to catalog projection without treating persisted metadata as authority. */ export function configuredUltraFastSlugs(models: readonly CatalogModel[]): ReadonlySet { const declarations = new Map(); for (const model of models) { diff --git a/structure/catalog.md b/structure/catalog.md index 73e477eb99d..cc23e9eaf87 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -112,7 +112,7 @@ to the native default singleton. A default must belong to the projected list. Fu is still not inferred from a GPT name. Stored configuration and native capability maps are unchanged. Request-time native effort clamps remain canonical-forward only. -The shared merge uses `src/codex/catalog/configured-tiers.ts` to project Ultra Fast into Codex's native speed picker only for an eligible captured service-tier wire explicitly mapping `ultrafast` to `ultrafast`. Both writers pass current exact routed slugs; templates, native/account rows and combos cannot grant the capability. Generated choices carry an exact-slug ownership marker so removing the declaration also removes stale generated tiers, even with operator retention enabled; publication leaves the default unchanged. Coverage: `tests/codex-integration/configured-ultrafast-picker.test.ts`. +The public builder in `src/codex/catalog/build-entries.ts` and shared merge use `src/codex/catalog/configured-tiers.ts` to project Ultra Fast only for an eligible captured service-tier wire explicitly mapping `ultrafast` to `ultrafast`. Live `/v1/models?client_version=...` discovery and both writers use current exact routed slugs; conflicting duplicates, templates, native/account rows and combos cannot grant capability. Live rows import no persisted operator tiers. Generated choices carry an exact-slug ownership marker so declaration withdrawal removes stale generated tiers even with operator retention enabled; publication leaves the default unchanged. Coverage: `tests/codex-integration/configured-ultrafast-picker.test.ts` and `tests/codex-integration/ultrafast-live-catalog.test.ts`. Separately, `src/codex/catalog/operator-tiers.ts` preserves a hand-supplied Ultra Fast tier from the exact persisted routed row only when `ultraFastTier` is enabled. Baselines and native templates cannot supply it. Current configured capability takes precedence; previously generated tiers cannot become operator evidence. Both overlays run after admission, preserve Fast policy and never create rows. Disabling `ultraFastTier` removes only independently supplied operator tiers; disabling the wire declaration removes generated ones. Operator defaults are retained only alongside an independently supplied tier declaration. Coverage: `tests/codex-integration/ultrafast-tier-honesty.test.ts`. Native rows are the one place the `ultraFastTier` flag itself adds a tier. `src/codex/catalog/native-ultrafast.ts` appends the documented `{ id: "ultrafast", name: "Ultrafast" }` descriptor to `service_tiers` and `"ultrafast"` to `additional_speed_tiers` on bare and trusted account-bound `gpt-6-astra` and `gpt-6.1-sol` rows, after the existing Fast tier and only when no Ultrafast entry is present. Codex reads the picker from `model_catalog_json` instead of ChatGPT's plan-aware live list, so without it Desktop never offers the choice. Each field it fills is recorded in the row's `opencodex_generated_service_tiers` marker, and disabling withdraws only marked fields (and only the unedited descriptor), so an operator-written native entry survives even when its text matches. Aliases, combos, reserve projections and other models are untouched, and the default tier never changes. Coverage: `tests/codex-integration/native-ultrafast-picker.test.ts`. diff --git a/tests/codex-integration/ultrafast-live-catalog.test.ts b/tests/codex-integration/ultrafast-live-catalog.test.ts new file mode 100644 index 00000000000..8df2ecc7897 --- /dev/null +++ b/tests/codex-integration/ultrafast-live-catalog.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildCatalogEntries } from "../../src/codex/catalog/build-entries"; +import { applyProviderConfigHints } from "../../src/codex/catalog/model-hints"; +import type { CatalogModel, RawEntry } from "../../src/codex/catalog/parsing"; +import type { OcxProviderConfig } from "../../src/types"; +import { repoRoot } from "../helpers/repo-root"; + +const slug = "gateway/task-model"; + +/** Derive the declaration from provider policy, as live discovery does. */ +function model(overrides: Partial = {}): CatalogModel { + return applyProviderConfigHints("gateway", { + adapter: "openai-responses", baseUrl: "https://provider.invalid/v1", liveModels: false, + models: ["task-model"], modelSupportsServiceTier: { "task-model": true }, + fastWire: { kind: "service-tier", canonicalToWire: { priority: "priority", ultrafast: "ultrafast" }, foreignCallerTiers: "verbatim" }, + ...overrides, + }, { provider: "gateway", id: "task-model" }); +} + +/** Use the public builder called by GET /v1/models?client_version, without a disk merge. */ +function build(models: CatalogModel[], template: RawEntry | null = null): RawEntry[] { + return buildCatalogEntries(template, [], models); +} + +describe("live Codex catalog Ultra Fast", () => { + test("the direct builder publishes the choice without changing its default or inputs", () => { + const models = [model()]; + const before = structuredClone(models); + const first = build(models); + const row = first.find(entry => entry.slug === slug)!; + expect(row.service_tiers.map((tier: { id: string }) => tier.id)).toEqual(["priority", "ultrafast"]); + expect(row.additional_speed_tiers).toEqual(["fast", "ultrafast"]); + expect(row.default_service_tier).toBeNull(); + expect(row.service_tier).toBeUndefined(); + expect(row.opencodex_managed_ultrafast).toBe(slug); + expect(build(models)).toEqual(first); + expect(models).toEqual(before); + }); + + test.each([ + ["ordinary Fast", { fastWire: undefined }], + ["model opt-out", { modelSupportsServiceTier: { "task-model": false } }], + ["wrong wire tier", { fastWire: { kind: "service-tier", canonicalToWire: { ultrafast: "priority" }, foreignCallerTiers: "verbatim" } }], + ] as [string, Partial][])("%s cannot borrow Ultra Fast from a template", (_label, override) => { + const template = { slug: "gpt-5.5", service_tiers: [{ id: "ultrafast", name: "Stale" }], additional_speed_tiers: ["ultrafast"] }; + const row = build([model(override)], template).find(entry => entry.slug === slug)!; + expect(row.service_tiers?.some((tier: { id: string }) => tier.id === "ultrafast") ?? false).toBe(false); + expect(row.additional_speed_tiers ?? []).not.toContain("ultrafast"); + expect(row.opencodex_managed_ultrafast).toBeUndefined(); + }); + + test.each([false, true])("conflicting duplicate declarations fail closed in either order: %s", reverse => { + const supported = model(); + const unsupported = { ...supported, supportsUltraFastTier: false }; + const rows = build(reverse ? [unsupported, supported] : [supported, unsupported]); + expect(rows.filter(row => row.slug === slug).length).toBeGreaterThan(0); + for (const row of rows.filter(row => row.slug === slug)) { + expect(row.additional_speed_tiers ?? []).not.toContain("ultrafast"); + expect(row.opencodex_managed_ultrafast).toBeUndefined(); + } + }); + + test("exact routed aliases receive the choice while combos and native/account rows keep their policy", () => { + const supported = model(); + const combo = { ...supported, provider: "combo", id: "task-combo", alias: "friendly-combo", nativeAlias: true }; + const entries = buildCatalogEntries(null, ["gpt-6.1-sol"], [ + { ...supported, alias: "gateway/friendly" }, combo, + ], undefined, false, "default", new Set(["friendly-combo"]), ["main"]); + expect(entries.find(row => row.slug === "gateway/friendly")!.additional_speed_tiers).toEqual(["fast", "ultrafast"]); + for (const nativeSlug of ["friendly-combo", "gpt-6.1-sol", "main/gpt-6.1-sol"]) { + const row = entries.find(entry => entry.slug === nativeSlug)!; + expect(row).toBeDefined(); + expect(row.opencodex_managed_ultrafast).toBeUndefined(); + } + }); +}); + +test("the real HTTP catalog repeatedly publishes only the declared routed tier", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-ultrafast-http-")); + const codexHome = join(root, "codex"); + const ocxHome = join(root, "ocx"); + mkdirSync(codexHome); mkdirSync(ocxHome); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "opencodex-catalog.json"\n'); + writeFileSync(join(codexHome, "opencodex-catalog.json"), JSON.stringify({ models: [ + { slug: "gpt-5.5", visibility: "list", base_instructions: "You are Codex." }, + ] })); + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ + port: 0, hostname: "127.0.0.1", defaultProvider: "gateway", ultraFastTier: true, + providers: { gateway: { + adapter: "openai-responses", baseUrl: "https://provider.invalid/v1", apiKey: "fixture-key", liveModels: false, + models: ["task-model", "ordinary-model"], modelSupportsServiceTier: { "task-model": true }, + fastWire: { kind: "service-tier", canonicalToWire: { priority: "priority", ultrafast: "ultrafast" }, foreignCallerTiers: "verbatim" }, + } }, + })); + const script = ` + const { startServer } = require("./src/server"); + const server = startServer(0); + const passes = []; + try { + for (let read = 0; read < 2; read++) { + const response = await fetch(new URL("/v1/models?client_version=0.160.0", server.url), { signal: AbortSignal.timeout(10000) }); + if (response.status !== 200) throw new Error("HTTP status " + response.status); + const body = await response.json(); + passes.push(body.models.filter(row => row.slug.startsWith("gateway/"))); + } + console.log("ULTRAFAST_HTTP_RESULT=" + JSON.stringify(passes)); + } finally { await server.stop(true); } + `; + try { + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot(), env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, + encoding: "utf8", maxBuffer: 4 * 1024 * 1024, timeout: 40_000, + }); + expect({ status: result.status, stderr: result.stderr }).toMatchObject({ status: 0 }); + const receipt = result.stdout.split("\n").find(line => line.startsWith("ULTRAFAST_HTTP_RESULT=")); + expect(receipt).toBeDefined(); + const passes = JSON.parse(receipt!.slice("ULTRAFAST_HTTP_RESULT=".length)) as RawEntry[][]; + expect(passes).toHaveLength(2); + expect(passes[1]).toEqual(passes[0]); + for (const rows of passes) { + const row = rows.find(entry => entry.slug === slug)!; + expect(row).toBeDefined(); + expect(row.service_tiers.map((tier: { id: string }) => tier.id)).toEqual(["priority", "ultrafast"]); + expect(row.additional_speed_tiers).toEqual(["fast", "ultrafast"]); + expect(row.default_service_tier).toBeNull(); + const ordinary = rows.find(entry => entry.slug === "gateway/ordinary-model")!; + expect(ordinary).toBeDefined(); + expect(ordinary.additional_speed_tiers ?? []).not.toContain("ultrafast"); + } + } finally { rmSync(root, { recursive: true, force: true }); } +}, 45_000); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f5047535934..184668bd9da 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1165,7 +1165,7 @@ "translator-budget.test.ts": "adapters", "translator-budget-lifetime.test.ts": "adapters", "catalog-async-lifetime.test.ts": "codex-integration", "transport-null-body.test.ts": "lib", "tray-proxy-deadline.test.ts": "windows", "tray-proxy.test.ts": "windows", "types-barrel-identity.test.ts": "config", "ultrafast-tier-honesty.test.ts": "codex-integration", - "configured-ultrafast-picker.test.ts": "codex-integration", + "configured-ultrafast-picker.test.ts": "codex-integration", "ultrafast-live-catalog.test.ts": "codex-integration", "umans-provider.test.ts": "providers", "uninstall.test.ts": "cli", "update-async-routes.test.ts": "server", "update-badge.test.ts": "update", "update-bun-ownership-lease.test.ts": "update", "update-desktop-badge.test.ts": "update", From aff44880c3a10202a3552c57d75a8e8069096a83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9A=B0=EC=B2=A0?= Date: Fri, 16 Oct 2026 09:58:00 +0900 Subject: [PATCH 2/7] fix(catalog): honor disabled vision sidecar for image input (carry #7126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carried from #7126. Co-authored-by: 정우철 --- .../src/content/docs/fr/guides/sidecars.md | 11 +- docs-site/src/content/docs/guides/sidecars.md | 10 +- .../src/content/docs/ja/guides/sidecars.md | 9 +- .../src/content/docs/ko/guides/sidecars.md | 11 +- .../src/content/docs/ru/guides/sidecars.md | 12 +- .../src/content/docs/tr/guides/sidecars.md | 12 +- .../src/content/docs/zh-cn/guides/sidecars.md | 10 +- .../src/content/docs/zh-tw/guides/sidecars.md | 8 +- scripts/test-layout/layout.json | 2 + src/codex/catalog/combo-member.ts | 19 +- src/codex/catalog/gather-capture.ts | 2 + src/codex/catalog/model-hints.ts | 37 ++-- src/codex/catalog/model-visibility.ts | 4 +- src/codex/catalog/provider-models.ts | 70 +++--- src/codex/catalog/routed-gather.ts | 25 +-- src/server/management/config-routes.ts | 8 + structure/catalog.md | 4 +- structure/gui-and-management-api.md | 2 +- .../catalog-vision-sidecar-disabled.test.ts | 205 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 + .../sidecar-settings-vision-catalog.test.ts | 139 ++++++++++++ 21 files changed, 512 insertions(+), 90 deletions(-) create mode 100644 tests/codex-integration/catalog-vision-sidecar-disabled.test.ts create mode 100644 tests/vision/sidecar-settings-vision-catalog.test.ts diff --git a/docs-site/src/content/docs/fr/guides/sidecars.md b/docs-site/src/content/docs/fr/guides/sidecars.md index 1fcc91cfec6..ee37d9ef37b 100644 --- a/docs-site/src/content/docs/fr/guides/sidecars.md +++ b/docs-site/src/content/docs/fr/guides/sidecars.md @@ -88,9 +88,12 @@ les échecs de génération postérieurs à l'envoi des en-têtes sont transmis Lorsqu'un modèle routé figure dans le `noVisionModels` de son fournisseur — ou est déclaré texte seul pour ce modèle via `modelInputModalities` — et qu'une requête porte une image, opencodex décrit chaque image **avant** l'appel principal et la remplace par du texte, à condition qu'un plan de sidecar vision soit disponible. Sans plan disponible, l'image brute -est supprimée au lieu d'être transmise à un backend texte seul. Le catalogue de modèles annonce l'entrée image pour chaque -modèle couvert par le sidecar. Les combos annoncent l'entrée image seulement lorsque chaque membre accepte les images, -nativement ou via un sidecar, et que le paramètre `imageInput` du combo n'est pas désactivé, afin que des clients comme +est supprimée au lieu d'être transmise à un backend texte seul. Avec `visionSidecar.enabled: false`, le catalogue de modèles +n'ajoute pas d'entrée image fournie par le sidecar aux modèles texte seuls, y compris les entrées personnalisées et les +membres des combos. La prise en charge native des images et les modalités image explicitement configurées sont conservées. +Si `enabled` est omis, l'annonce des capacités du sidecar reste activée ; la présence dans le catalogue ne garantit pas +que les identifiants ou la capacité du sidecar sont disponibles. Les combos annoncent l'entrée image seulement lorsque +chaque membre accepte les images, nativement ou via un sidecar activé, et que le paramètre `imageInput` du combo n'est pas désactivé, afin que des clients comme l'application Codex autorisent les pièces jointes au lieu de les bloquer avant l'exécution du sidecar. Lorsque `visionSidecar.model` est absent ou vide, le chemin d'exécution OpenAI, le tableau de bord et l'API de gestion utilisent le modèle de repli `gpt-5.6-luna`. Au démarrage, une ancienne valeur `gpt-5.6-luna` explicitement enregistrée @@ -164,6 +167,8 @@ le délai d'attente et la limite précédemment choisis. les clés omises inchangées. `timeoutMs` utilise les limites entières de l'environnement d'exécution (1–2147483647 ms). +Un changement de l'état d'activation effectif de Vision via `PUT /api/sidecar-settings` actualise le catalogue et le cache gérés après l'enregistrement ; `catalogRefreshPending=true` signale que cette actualisation n'a pas abouti et ne constitue pas un accusé de réception du client. + La carte du service auxiliaire de recherche web reprend la même forme de contrôle : la première ligne du sélecteur de modèle est **Désactivé (Off)**. La désactivation arrête l'interception de `web_search` par OpenCodex et l'intégration Codex écrit `web_search = "disabled"` dans diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index 09e52d28db2..1e353a4a48b 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -141,7 +141,11 @@ failures after response headers have started are delivered as `response.failed` Image routing is capability-aware. Before an image-bearing upstream send, opencodex resolves the selected model's effective input modalities from runtime provider evidence, explicit operator declarations, backend/registry metadata, and generated vendor metadata. A target positively known to be text-only goes through the Vision Sidecar first; the image is described **before** the main call and replaced inline with text. A target positively known to support images receives the image directly. Unknown custom models keep the existing compatibility behavior rather than being guessed text-only. For the canonical ChatGPT Codex route, opencodex uses the `openai-codex` metadata bundle rather than public OpenAI API metadata, so backend-specific modality differences are respected. The native Chat fast path uses the same gate and cannot bypass a known text-only verdict. Without an available sidecar plan, raw images are stripped before a proven text-only backend. -Combos advertise image input only when every member accepts images, either natively or through a +With `visionSidecar.enabled: false`, the model catalog does not add sidecar-backed image input to +text-only models, including custom rows and combo members. Native image support and explicitly +configured image modalities are preserved. Omitting `enabled` keeps sidecar advertisement on; +catalog visibility does not establish that sidecar credentials or capacity are available. +Combos advertise image input only when every member accepts images, either natively or through an enabled sidecar, and the combo's `imageInput` setting is not disabled, so clients such as the Codex app allow attachments instead of blocking them before the sidecar runs. When `visionSidecar.model` is absent or blank, the OpenAI execution path, Dashboard, and management API @@ -228,6 +232,10 @@ timeout, and limit. omitted keys unchanged. `timeoutMs` uses the runtime integer bounds (1–2147483647 ms). +When the effective Vision switch changes, saving also refreshes the managed Codex catalog and +model cache. The response reports `catalogRefreshPending: true` if that refresh did not complete; +the saved switch is retained, and catalog publication does not confirm that a running client has reloaded it. + The web-search sidecar card carries the same control shape: the model picker's first row is **Off**. Off does two things, and the second one is the reason the row exists. OpenCodex stops intercepting `web_search`, and the Codex integration writes Codex's own diff --git a/docs-site/src/content/docs/ja/guides/sidecars.md b/docs-site/src/content/docs/ja/guides/sidecars.md index 4d623877c98..0ffc670f9a8 100644 --- a/docs-site/src/content/docs/ja/guides/sidecars.md +++ b/docs-site/src/content/docs/ja/guides/sidecars.md @@ -72,8 +72,11 @@ stall は全体生成 timeout ではありません。SSE 開始前の失敗は ルーティングモデルが該当プロバイダーの `noVisionModels` にある、またはそのモデルが `modelInputModalities` でテキスト専用と宣言され、リクエストに画像が来る場合、opencodex は利用可能な ビジョンサイドカー計画があるときに限り、メイン呼び出し**前に**各画像を説明したテキストに差し替えます。 -計画が利用できない場合は、生の画像をテキスト専用バックエンドへ転送せず削除します。モデルカタログは -サイドカー対象の各モデルに画像入力を広告します。コンボは、すべてのメンバーがネイティブまたはサイドカーを +計画が利用できない場合は、生の画像をテキスト専用バックエンドへ転送せず削除します。`visionSidecar.enabled: false` の場合、 +モデルカタログはカスタムの項目やコンボのメンバーを含むテキスト専用モデルに、サイドカーによる画像入力を追加しません。 +ネイティブの画像対応と明示的に設定された画像モダリティは維持されます。`enabled` を省略した場合、サイドカーによる +画像入力の広告は引き続き有効です。カタログに表示されることは、サイドカーの認証情報や処理容量が利用可能であることを +保証しません。コンボは、すべてのメンバーがネイティブまたは有効化されたサイドカーを 通じて画像を受け入れ、かつコンボの `imageInput` 設定が無効でない場合にのみ画像入力を広告します。これにより Codex アプリなどのクライアントは、サイドカー実行前に添付をブロックせず許可できます。`visionSidecar.model` が未設定または空の場合、 OpenAI 実行経路、ダッシュボード、管理 API は `gpt-5.6-luna` をフォールバックとして使います。起動時には @@ -137,6 +140,8 @@ OpenAI 実行経路、ダッシュボード、管理 API は `gpt-5.6-luna` を `PUT /api/sidecar-settings` は同じフィールドを受け付けます。部分更新では省略したキーをそのまま残します。`timeoutMs` はランタイムの整数範囲(1–2147483647 ms)を使います。 +`PUT /api/sidecar-settings` によりビジョンの実効的なオン/オフ状態が変わると、保存後に管理対象のカタログとキャッシュが更新されますが、`catalogRefreshPending=true` は更新が完了しなかったことを示し、クライアントによる受領確認を意味しません。 + Web 検索サイドカーのカードも同じ構成です。モデルピッカーの先頭行が **オフ (Off)** です。オフにすると OpenCodex は `web_search` への介入をやめ、Codex 統合は `~/.codex/config.toml` に `web_search = "disabled"` を書き込みます。Codex は自身のモードがそうなるまでネイティブの diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index b1aca7ee1b1..9716fc27937 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -74,8 +74,11 @@ stall은 전체 생성 timeout이 아닙니다. SSE가 시작되기 전 실패 라우팅 모델이 해당 프로바이더의 `noVisionModels`에 있거나 해당 모델이 `modelInputModalities`에서 텍스트 전용으로 선언되어 있고 요청에 이미지가 들어오면, opencodex는 사용 가능한 비전 사이드카 계획이 있을 때에만 메인 호출 **전에** 각 이미지를 설명한 텍스트로 바꿉니다. 사용 가능한 계획이 없으면 원본 이미지는 텍스트 전용 -백엔드로 전달되지 않고 제거됩니다. 모델 카탈로그는 사이드카로 처리되는 모든 모델에 image input을 알립니다. -콤보는 모든 멤버가 네이티브로 또는 사이드카를 통해 이미지를 수용하고 콤보의 `imageInput` 설정이 비활성화되지 않은 +백엔드로 전달되지 않고 제거됩니다. `visionSidecar.enabled: false`이면 모델 카탈로그는 사용자 정의 모델과 +콤보 멤버를 포함한 텍스트 전용 모델에 사이드카 기반 이미지 입력을 추가하지 않습니다. 네이티브 이미지 지원과 +명시적으로 설정한 이미지 입력은 유지됩니다. `enabled`를 생략하면 사이드카 기반 표시는 켜진 상태이며, +카탈로그 표시는 사이드카의 인증 정보나 사용 가능 용량을 보장하지 않습니다. +콤보는 모든 멤버가 네이티브로 또는 활성화된 사이드카를 통해 이미지를 수용하고 콤보의 `imageInput` 설정이 비활성화되지 않은 경우에만 image input을 알립니다. 따라서 Codex 앱 같은 클라이언트는 사이드카가 실행되기 전에 첨부를 차단하지 않고 허용합니다. `visionSidecar.model`이 없거나 빈 값이면 OpenAI 실행 경로, Dashboard, 관리 API는 `gpt-5.6-luna`를 폴백으로 사용합니다. 시작 시 명시적으로 @@ -139,6 +142,10 @@ OpenAI 실행 경로, Dashboard, 관리 API는 `gpt-5.6-luna`를 폴백으로 `PUT /api/sidecar-settings`는 같은 필드를 받습니다. 부분 업데이트는 보내지 않은 키를 유지합니다. `timeoutMs`는 런타임 정수 범위(1–2147483647 ms)를 사용합니다. +비전의 실제 활성화 상태가 바뀌면 저장 후 관리되는 Codex 카탈로그와 모델 캐시도 갱신합니다. +갱신을 완료하지 못하면 응답의 `catalogRefreshPending`이 `true`가 되며 저장된 설정은 유지됩니다. +카탈로그 갱신 완료가 실행 중인 클라이언트의 다시 읽기를 확인하는 것은 아닙니다. + 웹 검색 사이드카 카드도 같은 구성입니다. 모델 선택기의 첫 행은 **끔 (Off)** 행입니다. 끄면 OpenCodex가 `web_search` 가로채기를 멈추고 Codex 통합이 `~/.codex/config.toml`에 `web_search = "disabled"`를 씁니다. Codex는 자체 모드가 그렇게 될 때까지 네이티브 호스팅 diff --git a/docs-site/src/content/docs/ru/guides/sidecars.md b/docs-site/src/content/docs/ru/guides/sidecars.md index 337c69062cc..57db4fe1022 100644 --- a/docs-site/src/content/docs/ru/guides/sidecars.md +++ b/docs-site/src/content/docs/ru/guides/sidecars.md @@ -84,9 +84,13 @@ SSE-событие `response.failed`. `modelInputModalities` явно указана только текстовая модальность — и запрос содержит изображение, opencodex описывает каждое изображение **до** основного вызова и заменяет его текстом, если доступен план vision-сайдкара. Без доступного плана исходное изображение удаляется, а не передаётся текстовому -бэкенду. Каталог моделей объявляет вход изображений для каждой модели, покрытой сайдкаром. Комбо -объявляют вход изображений только если каждый участник принимает изображения нативно или через -сайдкар и параметр комбо `imageInput` не отключён; поэтому такие клиенты, как приложение Codex, +бэкенду. При `visionSidecar.enabled: false` каталог моделей не добавляет поддержку ввода изображений +через сайдкар текстовым моделям, включая пользовательские записи и участников комбо. Нативная +поддержка изображений и явно настроенные модальности изображений сохраняются. Если `enabled` +не указан, объявление поддержки через сайдкар остаётся включённым; наличие в каталоге не подтверждает +доступность учётных данных или ресурсов сайдкара. Комбо объявляют вход изображений только если +каждый участник принимает изображения нативно или через включённый сайдкар и параметр комбо +`imageInput` не отключён; поэтому такие клиенты, как приложение Codex, разрешают вложения вместо их блокировки до запуска сайдкара. Если `visionSidecar.model` отсутствует или пуст, путь выполнения OpenAI, дашборд и API управления используют фолбэк `gpt-5.6-luna`. При запуске явно сохранённое устаревшее значение `gpt-5.4-mini` по-прежнему мигрирует на `gpt-5.6-luna`; миграция применяется только к сохранённому @@ -155,6 +159,8 @@ opencodex описывает каждое изображение **до** осн `PUT /api/sidecar-settings` принимает те же поля. Частичное обновление оставляет непереданные ключи без изменений. `timeoutMs` использует целочисленные границы рантайма (1–2147483647 мс). +Если через `PUT /api/sidecar-settings` меняется действующее состояние переключателя Vision, после сохранения обновляются управляемые каталог и кэш; `catalogRefreshPending=true` сообщает, что обновление не завершилось, и не служит подтверждением от клиента. + Карточка сайдкара web-search устроена так же: первая строка выбора модели — **Выкл. (Off)**. Выключение останавливает перехват `web_search` со стороны OpenCodex, а интеграция Codex записывает `web_search = "disabled"` в `~/.codex/config.toml`: до этого Codex diff --git a/docs-site/src/content/docs/tr/guides/sidecars.md b/docs-site/src/content/docs/tr/guides/sidecars.md index fdd53a327ac..a52dab0127f 100644 --- a/docs-site/src/content/docs/tr/guides/sidecars.md +++ b/docs-site/src/content/docs/tr/guides/sidecars.md @@ -107,9 +107,13 @@ Yönlendirilen model sağlayıcısının `noVisionModels` listesinde yer aldığ bu model için `modelInputModalities` ile salt metin olarak bildirildiğinde ve bir istek görsel taşıdığında, opencodex kullanılabilir bir vision sidecar planı varsa ana çağrıdan **önce** her görseli açıklar ve onu metinle değiştirir. Kullanılabilir -bir plan yoksa ham görsel salt metin arka ucuna iletilmek yerine kaldırılır. Model -kataloğu sidecar kapsamında olan her model için görsel girdisini bildirir. Kombolar, -her üye görselleri yerel olarak veya bir sidecar üzerinden kabul ettiğinde ve kombonun +bir plan yoksa ham görsel salt metin arka ucuna iletilmek yerine kaldırılır. +`visionSidecar.enabled: false` olduğunda model kataloğu, özel kayıtlar ve kombo üyeleri +dahil salt metin modellere sidecar destekli görsel girdisi eklemez. Yerel görsel desteği +ve açıkça yapılandırılmış görsel modaliteleri korunur. `enabled` belirtilmezse sidecar +destekli görsel girdisinin bildirilmesi açık kalır; katalogda görünmesi, sidecar kimlik +bilgilerinin veya kapasitesinin kullanılabilir olduğunu doğrulamaz. Kombolar, +her üye görselleri yerel olarak veya etkinleştirilmiş bir sidecar üzerinden kabul ettiğinde ve kombonun `imageInput` ayarı devre dışı olmadığında görsel girdisini bildirir; böylece Codex uygulaması gibi istemciler, sidecar çalışmadan önce ekleri engellemek yerine kabul eder. `visionSidecar.model` olmadığında veya boş @@ -196,6 +200,8 @@ akıl yürütmeyi, zaman aşımını ve sınırı korur. atlanan anahtarları değiştirmeden bırakır. `timeoutMs` çalışma zamanı tamsayı sınırlarını kullanır (1–2147483647 ms). +Vizyon'un geçerli açma/kapatma durumunu `PUT /api/sidecar-settings` üzerinden değiştirmek, kayıttan sonra yönetilen kataloğu ve önbelleği yeniler; `catalogRefreshPending=true`, yenilemenin tamamlanmadığını bildirir ve istemci onayı anlamına gelmez. + Web arama sidecar kartı aynı denetim yapısını taşır: model seçicisinin ilk satırı **Kapalı (Off)**'dır. Kapatmak OpenCodex'in `web_search` yakalamasını durdurur ve Codex entegrasyonu `~/.codex/config.toml` dosyasına `web_search = "disabled"` yazar; çünkü Codex kendi diff --git a/docs-site/src/content/docs/zh-cn/guides/sidecars.md b/docs-site/src/content/docs/zh-cn/guides/sidecars.md index 15605d2dcf9..b959040db09 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-cn/guides/sidecars.md @@ -66,8 +66,10 @@ OAuth 账户时使用 `anthropic`,否则使用 `openai`。显式选择 `anthro 当路由模型列在其 provider 的 `noVisionModels` 中,或该模型在 `modelInputModalities` 中被声明为仅文本, 并且请求包含图像时,只要有可用的 vision sidecar plan,opencodex 就会在主调用**之前**描述每张图像并用文字替换图像。 -如果没有可用 plan,原始图像会被移除,而不会继续转发给纯文本后端。模型目录会为每个由 sidecar 覆盖的模型声明图像输入。 -只有当每个 combo 成员都能原生或通过 sidecar 接受图像、且 combo 的 `imageInput` 设置未禁用时,combo 才会声明图像输入; +如果没有可用 plan,原始图像会被移除,而不会继续转发给纯文本后端。当 `visionSidecar.enabled: false` 时, +模型目录不会为纯文本模型添加由 sidecar 提供的图像输入,包括自定义模型和 combo 成员。原生图像支持和显式配置的 +图像输入会保留。省略 `enabled` 时,默认仍会声明 sidecar 图像支持;目录中的声明不保证 sidecar 凭据或容量可用。 +只有当每个 combo 成员都能原生或通过已启用的 sidecar 接受图像、且 combo 的 `imageInput` 设置未禁用时,combo 才会声明图像输入; 这样 Codex 应用等客户端会允许附件,而不会在 sidecar 运行前阻止它们。当 `visionSidecar.model` 缺失或为空时,OpenAI 执行路径、 Dashboard 和管理 API 都使用 `gpt-5.6-luna` 作为回退。启动时仍会把明确保存的旧 `gpt-5.4-mini` 值迁移到 `gpt-5.6-luna`;该迁移只作用于已保存值,不适用于缺失的 model 字段。 @@ -126,6 +128,10 @@ Dashboard 和管理 API 都使用 `gpt-5.6-luna` 作为回退。启动时仍会 `PUT /api/sidecar-settings` 接受相同字段。部分更新会保留未提交的键。`timeoutMs` 使用运行时整数边界(1–2147483647 毫秒)。 +Vision 的实际启用状态改变时,保存后也会刷新受管理的 Codex 目录和模型缓存。 +若刷新未完成,响应会返回 `catalogRefreshPending: true`,已保存的开关设置仍然保留。 +目录发布成功并不表示运行中的客户端已重新加载目录。 + Web 搜索 sidecar 卡片采用相同的控件形态:模型选择器的第一行是 **关闭 (Off)**。关闭会停止 OpenCodex 对 `web_search` 的拦截,同时 Codex 集成会把 `web_search = "disabled"` 写入 `~/.codex/config.toml`;因为 Codex 在自身模式如此声明前会一直声明其原生托管的 diff --git a/docs-site/src/content/docs/zh-tw/guides/sidecars.md b/docs-site/src/content/docs/zh-tw/guides/sidecars.md index fae3319f226..bc200704d04 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-tw/guides/sidecars.md @@ -66,8 +66,10 @@ OAuth 帳號時使用 `anthropic`,否則使用 `openai`。明確選擇 `anthro 當路由模型列在其 provider 的 `noVisionModels` 中,或該模型在 `modelInputModalities` 中被宣告為僅文字, 且請求包含圖像時,只要有可用的 vision sidecar plan,opencodex 就會在主呼叫**之前**描述每張圖像並用文字替換圖像。 -若沒有可用 plan,原始圖像會被移除,不會繼續轉送給純文字後端。模型目錄會為每個由 sidecar 處理的模型宣告圖像輸入。 -只有當每個 combo 成員都能原生或透過 sidecar 接受圖像,且 combo 的 `imageInput` 設定未停用時,combo 才會宣告圖像輸入; +若沒有可用 plan,原始圖像會被移除,不會繼續轉送給純文字後端。當 `visionSidecar.enabled: false` 時, +模型目錄不會為純文字模型新增由 sidecar 提供的圖像輸入,包括自訂項目與 combo 成員。原生圖像支援及明確設定的圖像模態會保留。 +省略 `enabled` 時,仍會宣告 sidecar 提供的圖像輸入;目錄中顯示這項能力不代表已確認 sidecar 的憑證或處理容量可用。 +只有當每個 combo 成員都能原生或透過已啟用的 sidecar 接受圖像,且 combo 的 `imageInput` 設定未停用時,combo 才會宣告圖像輸入; 如此 Codex 應用程式等用戶端會允許附件,而不會在 sidecar 執行前阻擋它們。Dashboard 和管理 API 目前顯示的預設值是 `gpt-5.6-luna`,啟動時也會把明確儲存的舊 `gpt-5.4-mini` 值遷移到 Luna。只有在 `visionSidecar.model` 欄位不存在或為空字串時,vision 執行路徑才會使用程式碼中的 `gpt-5.6-luna` 回退值。 @@ -121,6 +123,8 @@ OAuth 帳號時使用 `anthropic`,否則使用 `openai`。明確選擇 `anthro `PUT /api/sidecar-settings` 接受相同欄位。部分更新會保留未提交的鍵。`timeoutMs` 使用執行時整數邊界(1–2147483647 毫秒)。 +透過 `PUT /api/sidecar-settings` 變更 Vision 開關的實際生效狀態後,儲存完成便會重新整理受管理的目錄與快取;`catalogRefreshPending=true` 表示重新整理未完成,並非用戶端的確認回覆。 + Web 搜尋 sidecar 卡片採用相同的控制項形態:模型選擇器的第一列是 **關閉 (Off)**。關閉會停止 OpenCodex 對 `web_search` 的攔截,同時 Codex 整合會把 `web_search = "disabled"` 寫入 `~/.codex/config.toml`;因為 Codex 在自身模式如此宣告前會一直宣告其原生託管的 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 164d4ecb134..cf38cdac454 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -298,6 +298,7 @@ "catalog-seed-window-fill.test.ts": "codex-integration", "catalog-slug-uniqueness-boundary.test.ts": "codex-integration", "catalog-verbosity-default.test.ts": "codex-integration", + "catalog-vision-sidecar-disabled.test.ts": "codex-integration", "catalog-vision-sidecar-modalities.test.ts": "codex-integration", "catalog-zero-credit-picker.test.ts": "codex-integration", "chat-completions-deferred-tools.test.ts": "responses", "passthrough-deferred-tools.test.ts": "responses", @@ -1670,6 +1671,7 @@ "sidecar-abort.test.ts": "vision", "sidecar-auth.test.ts": "vision", "sidecar-candidates.test.ts": "vision", + "sidecar-settings-vision-catalog.test.ts": "vision", "sidecar-settings-vision-controls.test.ts": "vision", "sidecar-settings-vision-filter.test.ts": "vision", "sidecar-settings-web-search-gate.test.ts": "vision", diff --git a/src/codex/catalog/combo-member.ts b/src/codex/catalog/combo-member.ts index 1daf8bad13d..75adbb54b2d 100644 --- a/src/codex/catalog/combo-member.ts +++ b/src/codex/catalog/combo-member.ts @@ -100,7 +100,7 @@ import type { CatalogSourceEvidence, CatalogTrustedOpenAiApiPolicySnapshot, } from "../convergence-types"; -import { applyProviderConfigHints, configuredAutoCompactTokenLimit, positiveSafeInteger } from "./model-hints"; +import { applyProviderConfigHints, configuredAutoCompactTokenLimit, positiveSafeInteger, withVisionSidecarInput } from "./model-hints"; /** Model ids each provider must retain for combo catalog derivation (OCX-111). */ export function configuredComboTargetModelsByProvider( @@ -219,6 +219,7 @@ export function resolveComboCatalogMember( callerFallback?: ComboCatalogMemberFallback, metadataModelIdCaseFold?: boolean, metadataConfigDir?: string, + visionSidecarEnabled = true, ): CatalogModel | undefined { const existing = memberByKey.get(targetKey(target)); const prov = providers.get(target.provider); @@ -227,6 +228,16 @@ export function resolveComboCatalogMember( // is unusable for catalog derivation while the provider is off. if (prov?.disabled === true) return undefined; + /** Preserve custom modality overrides while filling missing provider metadata. */ + const withProviderHints = (model: CatalogModel): CatalogModel => { + const hinted = prov + ? applyProviderConfigHints(target.provider, prov, model, contextCap, metadataModelIdCaseFold, undefined, metadataConfigDir, visionSidecarEnabled) + : model; + return model.catalogKind === CODEX_CUSTOM_MODEL_CATALOG_KIND && model.inputModalities !== undefined + ? withVisionSidecarInput({ ...hinted, inputModalities: [...model.inputModalities] }, prov, visionSidecarEnabled) + : hinted; + }; + const withFallbackMetadata = (member: CatalogModel): CatalogModel => { const contextWindow = typeof member.contextWindow === "number" && member.contextWindow > 0 ? member.contextWindow @@ -276,7 +287,7 @@ export function resolveComboCatalogMember( // supplies a vision sidecar. Apply the same provider hints used for thin // rows before deriving a combo from this complete row. const hinted = prov && isModelVisionSidecarConsumer(prov, existing.id) - ? applyProviderConfigHints(target.provider, prov, existing, contextCap, metadataModelIdCaseFold, undefined, metadataConfigDir) + ? withProviderHints(existing) : existing; const capped = applyProviderContextCap(hinted.contextWindow, contextCap); if (capped === undefined || capped === existing.contextWindow) { @@ -298,9 +309,7 @@ export function resolveComboCatalogMember( id: target.model, provider: target.provider, }; - const hinted = prov - ? applyProviderConfigHints(target.provider, prov, base, contextCap, metadataModelIdCaseFold, undefined, metadataConfigDir) - : base; + const hinted = withProviderHints(base); const hintedContext = typeof hinted.contextWindow === "number" && hinted.contextWindow > 0 ? hinted.contextWindow : undefined; diff --git a/src/codex/catalog/gather-capture.ts b/src/codex/catalog/gather-capture.ts index 5ada699e336..5653a08e86c 100644 --- a/src/codex/catalog/gather-capture.ts +++ b/src/codex/catalog/gather-capture.ts @@ -564,6 +564,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco }; } +/** Coalesce only gathers with the same model inventory and effective sidecar visibility policy. */ export function gatherFlightKey(config: OcxConfig): string { const providers = Object.entries(config.providers) .filter(([, prov]) => prov.disabled !== true) @@ -571,6 +572,7 @@ export function gatherFlightKey(config: OcxConfig): string { .sort((a, b) => String(a.n).localeCompare(String(b.n))); const assembly = stableJson({ providers, + visionSidecarEnabled: config.visionSidecar?.enabled !== false, disabledModels: [...(config.disabledModels ?? [])].sort(), combos: config.combos ?? {}, customModels: (config.customModels ?? []).map((cm) => ({ diff --git a/src/codex/catalog/model-hints.ts b/src/codex/catalog/model-hints.ts index e0a78c7768e..6d9a13d060f 100644 --- a/src/codex/catalog/model-hints.ts +++ b/src/codex/catalog/model-hints.ts @@ -252,6 +252,17 @@ export function configuredReasoningSummarySupport(prov: OcxProviderConfig | unde return modelRecordValue(prov.modelReasoningSummaryDelivery, id) !== undefined ? true : undefined; } +/** Add attachment metadata at publication time, independently of cached provider evidence. */ +export function withVisionSidecarInput( + model: CatalogModel, + provider: OcxProviderConfig | undefined, + enabled = true, +): CatalogModel { + if (!enabled || !provider || !isModelVisionSidecarConsumer(provider, model.id)) return model; + const modalities = model.inputModalities ?? ["text"]; + return modalities.includes("image") ? model : { ...model, inputModalities: [...modalities, "image"] }; +} + /** Reapply configured limits and captured wire capabilities without trusting stale discovery hints. */ export function applyProviderConfigHints( name: string, @@ -261,6 +272,7 @@ export function applyProviderConfigHints( metadataModelIdCaseFold?: boolean, effectiveAlias?: string | null, metadataConfigDir?: string, + includeVisionSidecar = true, ): CatalogModel { const staticPolicy = resolveModelPolicy({ providerName: name, @@ -289,18 +301,7 @@ export function applyProviderConfigHints( // Removing an exact capability row therefore returns this projection to legacy-map inference. const resolvedModalities = staticPolicy.model.inputModalities ?? model.inputModalities ?? (azureMetadata?.input ? modelInputModalities({ id: model.id, input_modalities: azureMetadata.input }, undefined) : undefined); - let inputModalities = resolvedModalities ? [...resolvedModalities] : undefined; - // The shared vision-sidecar consumer predicate keeps catalog advertisement and request-time - // planning aligned. The catalog must still advertise image input — the Codex app - // gates attachments client-side on input_modalities, and a text-only entry would block images - // before the sidecar ever runs ("This model does not support image inputs"). Discovery-derived - // text-only rows stay untouched: the runtime predicate only reads these two config sources, so - // it would not convert those. - const sidecarCovered = isModelVisionSidecarConsumer(prov, model.id); - if (sidecarCovered) { - const base = inputModalities ?? model.inputModalities ?? ["text"]; - inputModalities = base.includes("image") ? [...base] : [...base, "image"]; - } + const inputModalities = resolvedModalities ? [...resolvedModalities] : undefined; const reasoningEfforts = configuredReasoningEfforts(prov, model.id); const suppressSyntheticMax = modelRecordValue(prov.modelSuppressSyntheticMax, model.id) ?? (model.antigravityEffortWireModelIds !== undefined); @@ -337,7 +338,7 @@ export function applyProviderConfigHints( ?? azureWindow ?? inferredWindow?.contextWindow ?? (providerCap !== undefined ? resolveUnknownRoutedContextWindow(providerCap) : undefined); - const hinted = { + const hinted = withVisionSidecarInput({ ...modelWithoutServiceTier, ...(!model.contextWindowInference && previousContextCap !== undefined ? { contextCap: previousContextCap, contextCapped: previousContextCapped } : {}), @@ -368,7 +369,7 @@ export function applyProviderConfigHints( ? { parallelToolCalls: true } : {}), ...(prov.codexToolMode !== undefined ? { codexToolMode: prov.codexToolMode } : {}), - }; + }, prov, includeVisionSidecar); const capped = applyProviderContextCap(hinted.contextWindow, providerCap); const withCap = providerCap !== undefined ? capped !== hinted.contextWindow @@ -397,6 +398,7 @@ export function applyProviderConfigHints( }; } +/** Project a configured row, optionally deferring sidecar attachment metadata until publication. */ export function catalogHintsFromProviderConfig( name: string, prov: OcxProviderConfig, @@ -405,12 +407,14 @@ export function catalogHintsFromProviderConfig( metadataModelIdCaseFold?: boolean, effectiveAlias?: string | null, metadataConfigDir?: string, + includeVisionSidecar = true, ): Partial { - const hinted = applyProviderConfigHints(name, prov, { id, provider: name }, contextCap, metadataModelIdCaseFold, effectiveAlias, metadataConfigDir); + const hinted = applyProviderConfigHints(name, prov, { id, provider: name }, contextCap, metadataModelIdCaseFold, effectiveAlias, metadataConfigDir, includeVisionSidecar); const { provider: _provider, id: _id, ...hints } = hinted; return hints; } +/** Reapply current hints to cached rows without mutating their provider evidence. */ export function applyConfigHintsToCachedModels( name: string, prov: OcxProviderConfig, @@ -419,8 +423,9 @@ export function applyConfigHintsToCachedModels( metadataModelIdCaseFold?: boolean, effectiveAlias?: string | null, metadataConfigDir?: string, + includeVisionSidecar = true, ): CatalogModel[] { - return models.map(model => applyProviderConfigHints(name, prov, model, contextCap, metadataModelIdCaseFold, effectiveAlias, metadataConfigDir)); + return models.map(model => applyProviderConfigHints(name, prov, model, contextCap, metadataModelIdCaseFold, effectiveAlias, metadataConfigDir, includeVisionSidecar)); } /** Catalog slugs whose configured model must not gain a missing synthetic max rung. */ diff --git a/src/codex/catalog/model-visibility.ts b/src/codex/catalog/model-visibility.ts index 0e92a019d4e..0f0cc2f6cc6 100644 --- a/src/codex/catalog/model-visibility.ts +++ b/src/codex/catalog/model-visibility.ts @@ -231,6 +231,7 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { retainComboTargets?: boolean; metadataModelIdCaseFold?: boolean; metadataConfigDir?: string; + includeVisionSidecar?: boolean; }): { models: CatalogModel[]; droppedConfiguredIds: string[] } { const { name, @@ -242,6 +243,7 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { retainComboTargets = true, metadataModelIdCaseFold, metadataConfigDir, + includeVisionSidecar, } = opts; const out = [...opts.models]; const present = new Set(out.map(model => model.id)); @@ -250,7 +252,7 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { if (present.has(candidate.id)) continue; const dated = out.find(live => isDatedVariantId(live.id, candidate.id)); if (dated) { - out.push(applyProviderConfigHints(name, prov, { ...dated, id: candidate.id }, contextCap, metadataModelIdCaseFold, undefined, metadataConfigDir)); + out.push(applyProviderConfigHints(name, prov, { ...dated, id: candidate.id }, contextCap, metadataModelIdCaseFold, undefined, metadataConfigDir, includeVisionSidecar)); present.add(candidate.id); continue; } diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index c2340cc6b62..a2d680ce29b 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -117,7 +117,7 @@ import type { CatalogTrustedOpenAiApiPolicySnapshot, } from "../convergence-types"; import type { CapturedProviderGather, CatalogGatherProviderAuthOutcome, CatalogGatherProviderModelOutcome, ModelsAuthResolution, ModelsAuthResolver } from "./gather-capture"; -import { QUIET_AUTHORITATIVE_CATALOG_PROVIDERS, applyConfigHintsToCachedModels, applyProviderConfigHints, boundedOwnedBy, catalogHintsFromModelsApiItem, catalogHintsFromProviderConfig } from "./model-hints"; +import { QUIET_AUTHORITATIVE_CATALOG_PROVIDERS, applyConfigHintsToCachedModels, applyProviderConfigHints, boundedOwnedBy, catalogHintsFromModelsApiItem, catalogHintsFromProviderConfig, withVisionSidecarInput } from "./model-hints"; import { mergeConfiguredModelsIntoLiveCatalog, shouldExposeProviderModel, warnDroppedConfiguredIdsOnce } from "./model-visibility"; import { captureModelsRequest, captureProviderGather, materializeCapturedHeaders } from "./gather-capture"; @@ -168,7 +168,7 @@ export function observedModelsAuthResolver( }, }; } -/** Project captured discovery through config hints and retention; Auto-only Copilot exposes only Auto. */ +/** Project provider evidence without synthetic sidecar image input, which belongs to each gather. */ export async function fetchProviderModelsWithAuth( captured: CapturedProviderGather, ttlMs: number, @@ -211,7 +211,7 @@ export async function fetchProviderModelsWithAuth( const configured: CatalogModel[] = configuredIds.map(id => ({ id, provider: name, - ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), })); const withConfiguredRetention = ( models: CatalogModel[], @@ -228,6 +228,7 @@ export async function fetchProviderModelsWithAuth( retainComboTargets: options?.retainComboTargets, metadataModelIdCaseFold, metadataConfigDir, + includeVisionSidecar: false, }); if ( options?.warnDrops === true @@ -260,7 +261,7 @@ export async function fetchProviderModelsWithAuth( } return observed(ids.map(id => { const hints = catalogHintsFromProviderConfig(name, prov, id, contextCap, - metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir); + metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false); const observedWindow = kiroObservedContextWindow(id); return { id, provider: name, ...hints, ...(observedWindow !== undefined @@ -296,7 +297,7 @@ export async function fetchProviderModelsWithAuth( model => applyProviderConfigHints(name, prov, { id: model.id, provider: name, ...catalogHintsFromModelsApiItem(name, model as ProviderModelsApiItem), - }, contextCap, metadataModelIdCaseFold, captured.effectiveAlias)); + }, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, undefined, false)); if (!isCurrentCacheGeneration()) return observed(configured, "degraded"); markProviderDiscoveryOk(name, picker.length); // Retain callable manual/combo selectors, but do not advertise named selection @@ -326,7 +327,7 @@ export async function fetchProviderModelsWithAuth( : [{ id: prov.defaultModel, provider: name, - ...catalogHintsFromProviderConfig(name, prov, prov.defaultModel, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + ...catalogHintsFromProviderConfig(name, prov, prov.defaultModel, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), }]; const vertexDefaultSeed = seedVertexDefault ? configured[0] : undefined; const withVertexDefaultSeed = (models: CatalogModel[]): CatalogModel[] => ( @@ -348,13 +349,13 @@ export async function fetchProviderModelsWithAuth( const fresh = getFreshCached(name, ttlMs, Date.now(), authorityIdentity); if (fresh) { return observed(withConfiguredRetention( - applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), ), "authoritative"); } const scopedStale = getStaleCached(name, authorityIdentity); if (isModelsFetchCoolingDown(name, undefined, undefined, authorityIdentity) && scopedStale) { return observed(withConfiguredRetention( - applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), ), "degraded"); } const live = await fetchCodeBuddyModels(profile, apiKey); @@ -362,7 +363,7 @@ export async function fetchProviderModelsWithAuth( const discovered = live.models.map(id => ({ id, provider: name, - ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), })); const forCache = withConfiguredRetention(discovered, { retainComboTargets: false }); if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) { @@ -378,7 +379,7 @@ export async function fetchProviderModelsWithAuth( } const stale = getStaleCached(name, authorityIdentity); return observed(withConfiguredRetention( - stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir) : configured, + stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false) : configured, ), "degraded"); } if (name === "zed" && prov.adapter === "zed") { @@ -391,14 +392,14 @@ export async function fetchProviderModelsWithAuth( const cached = getFreshCached(name, ttlMs, Date.now(), authorityIdentity); if (cached) { return observed( - withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cached, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir)), + withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cached, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false)), "authoritative", ); } const stale = getStaleCached(name, authorityIdentity); if (isModelsFetchCoolingDown(name, undefined, undefined, authorityIdentity) && stale) { return observed( - withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir)), + withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false)), "degraded", ); } @@ -418,7 +419,7 @@ export async function fetchProviderModelsWithAuth( ...(model.maxOutputTokens ? { maxOutputTokens: model.maxOutputTokens } : {}), ...(model.supportsImages ? { inputModalities: ["text", "image"] } : {}), ...(reasoningEfforts?.length ? { reasoningEfforts } : {}), - ...catalogHintsFromProviderConfig(name, prov, model.id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + ...catalogHintsFromProviderConfig(name, prov, model.id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), } as CatalogModel; }); const forCache = withConfiguredRetention(discovered, { retainComboTargets: false }); @@ -433,7 +434,7 @@ export async function fetchProviderModelsWithAuth( markProviderDiscoveryFailed(name, { reason: "provider" }); } return observed( - withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir) : configured), + withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false) : configured), "degraded", ); } @@ -449,13 +450,13 @@ export async function fetchProviderModelsWithAuth( const fresh = getFreshCached(name, ttlMs, Date.now(), authorityIdentity); if (fresh) { return observed(withConfiguredRetention( - applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), ), "authoritative"); } const scopedStale = getStaleCached(name, authorityIdentity); if (isModelsFetchCoolingDown(name) && scopedStale) { return observed(withConfiguredRetention( - applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), ), "degraded"); } const live = await fetchQoderModels(profile, apiKey); @@ -463,7 +464,7 @@ export async function fetchProviderModelsWithAuth( const discovered = live.models.map(id => ({ id, provider: name, - ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), })); const forCache = withConfiguredRetention(discovered, { retainComboTargets: false }); if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) { @@ -479,7 +480,7 @@ export async function fetchProviderModelsWithAuth( } const stale = getStaleCached(name, authorityIdentity); return observed(withConfiguredRetention( - stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir) : configured, + stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false) : configured, ), "degraded"); } if (prov.adapter === "devin") { @@ -494,7 +495,7 @@ export async function fetchProviderModelsWithAuth( const cachedDevin = getFreshCached(name, ttlMs, Date.now(), authorityIdentity); if (cachedDevin) { return observed( - withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedDevin, undefined, undefined, undefined, metadataConfigDir)), + withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedDevin, undefined, undefined, undefined, metadataConfigDir, false)), "authoritative", ); } @@ -502,7 +503,7 @@ export async function fetchProviderModelsWithAuth( const cooling = getStaleCached(name, authorityIdentity); return observed( withConfiguredRetention( - cooling ? applyConfigHintsToCachedModels(name, prov, cooling, undefined, undefined, undefined, metadataConfigDir) : configured, + cooling ? applyConfigHintsToCachedModels(name, prov, cooling, undefined, undefined, undefined, metadataConfigDir, false) : configured, ), "degraded", ); @@ -540,11 +541,11 @@ export async function fetchProviderModelsWithAuth( ...(liveResult.defaultEfforts[id] ? { defaultReasoningEffort: liveResult.defaultEfforts[id] } : {}), // The account catalog's per-base supportsImages vote collapses to one // modalities value. It spreads before the hints so exact - // modelCapabilities declarations, the legacy modelInputModalities - // record and the vision-sidecar rewrite keep winning — the live - // value survives only when none of them applies. + // modelCapabilities declarations and the legacy modelInputModalities + // record keep winning. Sidecar advertisement is added after discovery; + // the live value survives only when no explicit declaration applies. ...(liveResult.inputModalities[id]?.length ? { inputModalities: liveResult.inputModalities[id] } : {}), - ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir), + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), } as CatalogModel; }); const forCache = withConfiguredRetention(result, { retainComboTargets: false }); @@ -560,7 +561,7 @@ export async function fetchProviderModelsWithAuth( } const stale = getStaleCached(name, authorityIdentity); return observed( - withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale, undefined, undefined, undefined, metadataConfigDir) : configured), + withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale, undefined, undefined, undefined, metadataConfigDir, false) : configured), "degraded", ); } @@ -577,7 +578,7 @@ export async function fetchProviderModelsWithAuth( const cachedCursor = getFreshCached(name, ttlMs, Date.now(), authorityIdentity); if (cachedCursor) { return observed( - withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir)), + withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false)), "authoritative", ); } @@ -585,7 +586,7 @@ export async function fetchProviderModelsWithAuth( const cooling = getStaleCached(name, authorityIdentity); return observed( withConfiguredRetention( - cooling ? applyConfigHintsToCachedModels(name, prov, cooling, undefined, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir) : configured, + cooling ? applyConfigHintsToCachedModels(name, prov, cooling, undefined, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false) : configured, ), "degraded", ); @@ -631,7 +632,7 @@ export async function fetchProviderModelsWithAuth( const staleCursor = getStaleCached(name, authorityIdentity); return observed( withConfiguredRetention( - staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir) : configured, + staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false) : configured, ), "degraded", ); @@ -649,7 +650,7 @@ export async function fetchProviderModelsWithAuth( if (fresh) { return observed( withConfiguredRetention( - withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir)), + withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false)), ), "authoritative", ); // dedups Codex's frequent /v1/models polling within the TTL @@ -661,7 +662,7 @@ export async function fetchProviderModelsWithAuth( return observed( withConfiguredRetention( stale - ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir)) + ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false)) : failedDiscoveryConfigured, ), "degraded", @@ -706,7 +707,7 @@ export async function fetchProviderModelsWithAuth( return { models: withConfiguredRetention( stale - ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir)) + ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false)) : failedDiscoveryConfigured, ), fallback: stale ? "stale" : "configured", @@ -797,7 +798,7 @@ export async function fetchProviderModelsWithAuth( ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}), ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}), })); - const live = applyConfigHintsToCachedModels(name, prov, upstream, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir); + const live = applyConfigHintsToCachedModels(name, prov, upstream, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false); const forCache = azureMetadata ? upstream : withConfiguredRetention(live, { retainComboTargets: false }); if (!isCurrentCacheGeneration()) { return observed(withConfiguredRetention(configured), "degraded"); @@ -878,7 +879,7 @@ export async function fetchProviderModelsWithAuth( }; }) .filter(m => shouldExposeProviderModel(name, m.id)); - const live = applyConfigHintsToCachedModels(name, prov, upstream, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir); + const live = applyConfigHintsToCachedModels(name, prov, upstream, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false); // Capture the count BEFORE the alias/configured augmentation below pushes extra rows into // `live`; otherwise configured entries would be reported as discovered ones. const liveModelCount = live.length; @@ -925,6 +926,7 @@ export async function fetchProviderModelsWithAuth( } } +/** Preserve the provider-only API's default-enabled projection without widening cached evidence. */ export async function fetchProviderModels( name: string, prov: OcxProviderConfig, @@ -937,5 +939,5 @@ export async function fetchProviderModels( ttlMs, contextCap, refreshingModelsAuthResolver, - )).models; + )).models.map(model => withVisionSidecarInput(model, captured.provider)); } diff --git a/src/codex/catalog/routed-gather.ts b/src/codex/catalog/routed-gather.ts index 790556a8c6b..99f7e64d3db 100644 --- a/src/codex/catalog/routed-gather.ts +++ b/src/codex/catalog/routed-gather.ts @@ -35,7 +35,6 @@ import { import type { OcxConfig, OcxProviderConfig } from "../../types"; import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; -import { isModelVisionSidecarConsumer } from "../../vision/eligibility"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { @@ -103,7 +102,7 @@ import type { CatalogTrustedOpenAiApiPolicySnapshot, } from "../convergence-types"; import type { CatalogGatherProviderAuthOutcome, CatalogGatherProviderModelOutcome, GatherFlightCapture, ModelsAuthResolverFactory } from "./gather-capture"; -import { applyProviderConfigHints, configuredAutoCompactTokenLimit, configuredMaxInputTokens, configuredReasoningSummarySupport, modelInputModalities, routedMaxOutputTokens } from "./model-hints"; +import { applyProviderConfigHints, configuredAutoCompactTokenLimit, configuredMaxInputTokens, configuredReasoningSummarySupport, modelInputModalities, routedMaxOutputTokens, withVisionSidecarInput } from "./model-hints"; import { resolveComboCatalogMember } from "./combo-member"; import { captureGatherFlight, captureTrustedOpenAiApiPolicy, gatherFlightKey, keyedGatherBytesIdentity, withCanonicalOpenAiForwardAuthDefault } from "./gather-capture"; import { fetchProviderModelsWithAuth, observedModelsAuthResolver, refreshingModelsAuthResolver } from "./provider-models"; @@ -330,6 +329,7 @@ async function gatherRoutedModelsUncached( const localProviderAuthOutcomes = capture.providerAuthOutcomes; const resolveAuth = capture.authResolver; const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS; + const visionSidecarEnabled = config.visionSidecar?.enabled !== false; // Persisted provider entries can predate newer registry fields (noVisionModels, // modelInputModalities, ...). The ROUTER merges registry seeds at request time // (routedProviderConfig), so the proxy behaves correctly — the catalog listing must see the @@ -337,6 +337,8 @@ async function gatherRoutedModelsUncached( // vision-sidecar model advertised text-only, blocking image attachments app-side). // Enrich a CLONE: hydrated defaults must never leak into the persisted config. const activeProviders = capture.providers; + // Use admission-time provider clones for sidecar advertisement, custom rows and combo members. + const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider])); const providerResults = await Promise.all( activeProviders.map(provider => fetchProviderModelsWithAuth( provider, @@ -378,7 +380,9 @@ async function gatherRoutedModelsUncached( config, metadataModelIdCaseFoldByProvider, metadataConfigDirByProvider, + false, ) + .map(model => withVisionSidecarInput(model, enrichedByName.get(model.provider), visionSidecarEnabled)) // Drop image/video generation models (e.g. Grok image/video) by default. Cursor's static catalog // intentionally mirrors Cursor's public model table, including Gemini image preview, so the // exposure decision goes through shouldExposeRoutedModel (single choke point). @@ -482,9 +486,6 @@ async function gatherRoutedModelsUncached( ...(custom.codexToolMode !== undefined ? { codexToolMode: custom.codexToolMode } : {}), }); } - // Enriched (registry-hydrated) provider clones — shared by combo member synthesis and - // custom-model vision-sidecar inheritance so both see the same merged registry view. - const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider])); for (const id of listComboIds(config)) { const combo = getCombo(config, id); if (!combo) continue; @@ -521,6 +522,7 @@ async function gatherRoutedModelsUncached( nativeAliasFallback, metadataModelIdCaseFoldByProvider.get(target.provider), metadataConfigDirByProvider.get(target.provider), + visionSidecarEnabled, )) .filter((member): member is CatalogModel => member !== undefined); const derived = deriveComboCatalogModel(id, combo, members); @@ -714,7 +716,7 @@ async function gatherRoutedModelsUncached( const customContextHints = merged.contextWindow === undefined && effectiveProvider && canInferVendorContext(cm.provider, effectiveProvider) ? applyProviderConfigHints(cm.provider, effectiveProvider, { provider: cm.provider, id: cm.modelId }, providerContextCap(config, cm.provider), metadataModelIdCaseFoldByProvider.get(cm.provider), - undefined, metadataConfigDirByProvider.get(cm.provider)) + undefined, metadataConfigDirByProvider.get(cm.provider), false) : undefined; const contextResolved: CatalogModel = customContextHints?.contextWindow !== undefined ? { ...merged, contextWindow: customContextHints.contextWindow, @@ -762,13 +764,7 @@ async function gatherRoutedModelsUncached( : mergedWithHardBounds; const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider; // Reuse the request-time consumer predicate so custom rows cannot drift from catalog hints. - if (enrichedProvider && isModelVisionSidecarConsumer(enrichedProvider, mergedWithAutoCompact.id)) { - const current = mergedWithAutoCompact.inputModalities ?? ["text"]; - if (!current.includes("image")) { - return { ...mergedWithAutoCompact, inputModalities: [...current, "image"] }; - } - } - return mergedWithAutoCompact; + return withVisionSidecarInput(mergedWithAutoCompact, enrichedProvider, visionSidecarEnabled); }); // Custom rows override discovered rows that encode to the same Codex-facing slug. A provider whose // captured discovery declares an order (preferFirst) keeps each replacement in the discovered row's @@ -948,6 +944,7 @@ export function augmentRoutedModelsWithMetadata( caps?: Pick, metadataModelIdCaseFoldByProvider?: ReadonlyMap, metadataConfigDirByProvider?: ReadonlyMap, + includeVisionSidecar = true, ): CatalogModel[] { const out = [...models]; const indexByKey = new Map(out.map((model, index) => [`${model.provider}/${model.id}`, index])); @@ -977,6 +974,7 @@ export function augmentRoutedModelsWithMetadata( metadataModelIdCaseFoldByProvider?.get(provider), undefined, metadataConfigDirByProvider?.get(provider), + includeVisionSidecar, ) : seeded; continue; @@ -1001,6 +999,7 @@ export function augmentRoutedModelsWithMetadata( metadataModelIdCaseFoldByProvider?.get(provider), undefined, metadataConfigDirByProvider?.get(provider), + includeVisionSidecar, ) : {}), }); diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 08c6ed46f50..e4bfffe5caf 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -354,6 +354,7 @@ function publicVisionSidecarSettings( }; } +/** Serve validated settings and publish catalog-affecting changes after persistence. */ export async function handleConfigRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; const readStartupHealth = deps.getCachedStartupHealth ?? getCachedStartupHealth; @@ -1079,6 +1080,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise [id, 200_000])), + }, + }, + }); +} + +/** Supply local model-list responses; no provider or user configuration is contacted. */ +function modelsResponse(): Response { + return Response.json({ data: ids.map(id => ({ id, input_modalities: ["text"] })) }); +} + +/** Compare published Codex modalities rather than only the intermediate gather rows. */ +function modalities(models: CatalogModel[], id: string): unknown { + return buildCatalogEntries(null, [], models) + .find(row => row.slug === `${providerName}/${id}`)?.input_modalities; +} + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache(providerName); +}); + +describe("catalog respects the disabled vision sidecar", () => { + test.each([false, true, undefined])("static rows use enabled=%s without widening native authority", async enabled => { + globalThis.fetch = (() => { throw new Error("static catalog must not fetch"); }) as typeof fetch; + const rows = await gatherRoutedModels(config(enabled)); + expect(modalities(rows, "declared-text")).toEqual(enabled === false ? ["text"] : ["text", "image"]); + expect(modalities(rows, "no-vision")).toEqual(enabled === false ? ["text"] : ["text", "image"]); + expect(modalities(rows, "vision-native")).toEqual(["text", "image"]); + expect(modalities(rows, "vision-exact")).toEqual(["text", "image"]); + }); + + test.each([false, true])("custom rows preserve explicit modalities with enabled=%s", async enabled => { + const fixture = config(enabled); + fixture.customModels = [ + { id: "custom", provider: providerName, modelId: "custom-text", inputModalities: ["text"], addedAt: "2026-01-01" }, + { id: "override", provider: providerName, modelId: "no-vision", inputModalities: ["text", "image"], addedAt: "2026-01-01" }, + ]; + const rows = await gatherRoutedModels(fixture); + expect(modalities(rows, "custom-text")).toEqual(enabled ? ["text", "image"] : ["text"]); + expect(modalities(rows, "no-vision")).toEqual(["text", "image"]); + }); + + test.each([false, true])("missing combo members use enabled=%s", enabled => { + const fixture = config(enabled); + const member = resolveComboCatalogMember( + { provider: providerName, model: "declared-text" }, new Map(), + new Map(Object.entries(fixture.providers)), undefined, undefined, undefined, undefined, enabled, + ); + expect(member?.inputModalities).toEqual(enabled ? ["text", "image"] : ["text"]); + }); + + test.each([false, true])("gathered combos follow enabled=%s", async enabled => { + const fixture = config(enabled); + fixture.combos = { selected: { targets: [{ provider: providerName, model: "declared-text" }] } }; + const rows = buildCatalogEntries(null, [], await gatherRoutedModels(fixture)); + expect(rows.find(row => row.slug === "combo/selected")?.input_modalities) + .toEqual(enabled ? ["text", "image"] : ["text"]); + }); + + test.each(["declared-text", "custom-text"])("disabled sidecar preserves explicit custom image input in combo target %s", async modelId => { + const fixture = config(false); + fixture.customModels = [ + { id: "custom", provider: providerName, modelId, inputModalities: ["text", "image"], addedAt: "2026-01-01" }, + ]; + fixture.combos = { selected: { targets: [{ provider: providerName, model: modelId }] } }; + const models = await gatherRoutedModels(fixture); + expect(modalities(models, modelId)).toEqual(["text", "image"]); + expect(buildCatalogEntries(null, [], models).find(row => row.slug === "combo/selected")?.input_modalities) + .toEqual(["text", "image"]); + }); + + test("warm live cache does not retain synthetic image input across disable and re-enable", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return modelsResponse(); }) as typeof fetch; + const on = await gatherRoutedModels(config(true, true)); + const off = await gatherRoutedModels(config(false, true)); + const onAgain = await gatherRoutedModels(config(undefined, true)); + for (const id of ["declared-text", "no-vision"]) { + expect(modalities(on, id)).toEqual(["text", "image"]); + expect(modalities(off, id)).toEqual(["text"]); + expect(modalities(onAgain, id)).toEqual(["text", "image"]); + } + expect(modalities(off, "vision-native")).toEqual(["text", "image"]); + expect(calls).toBe(1); + }); + + test("provider-only default advertisement cannot widen a later disabled gather", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return modelsResponse(); }) as typeof fetch; + const fixture = config(false, true); + const providerRows = await fetchProviderModels(providerName, fixture.providers[providerName]!, 60_000); + expect(modalities(providerRows, "no-vision")).toEqual(["text", "image"]); + expect(modalities(await gatherRoutedModels(fixture), "no-vision")).toEqual(["text"]); + expect(calls).toBe(1); + }); + + test("stale cache and failure cooldown reproject the current sidecar setting", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return calls === 1 ? modelsResponse() : new Response("unavailable", { status: 503 }); + }) as typeof fetch; + const on = config(true, true); + on.modelCacheTtlMs = 0; + expect(modalities(await gatherRoutedModels(on), "no-vision")).toEqual(["text", "image"]); + const off = config(false, true); + off.modelCacheTtlMs = 0; + expect(modalities(await gatherRoutedModels(off), "no-vision")).toEqual(["text"]); + expect(modalities(await gatherRoutedModels(on), "no-vision")).toEqual(["text", "image"]); + expect(modalities(await gatherRoutedModels(off), "no-vision")).toEqual(["text"]); + expect(calls).toBe(2); + }); + + test("disabled projection preserves live native image evidence without widening discovery-only text rows", async () => { + globalThis.fetch = (async () => Response.json({ data: [ + { id: "live-native", input_modalities: ["text", "image"] }, + { id: "live-text", input_modalities: ["text"] }, + ] })) as typeof fetch; + const off = await gatherRoutedModels(config(false, true)); + const on = await gatherRoutedModels(config(true, true)); + for (const rows of [off, on]) { + expect(modalities(rows, "live-native")).toEqual(["text", "image"]); + expect(modalities(rows, "live-text")).toEqual(["text"]); + } + }); + + test.each([false, true])("mixed native and exact text-only combo uses enabled=%s", async enabled => { + const fixture = config(enabled); + fixture.providers[providerName]!.modelCapabilities!["declared-text"] = { inputModalities: ["text"] }; + fixture.combos = { mixed: { targets: [ + { provider: providerName, model: "declared-text" }, + { provider: providerName, model: "vision-native" }, + ] } }; + const entries = buildCatalogEntries(null, [], await gatherRoutedModels(fixture)); + expect(entries.find(row => row.slug === "combo/mixed")?.input_modalities) + .toEqual(enabled ? ["text", "image"] : ["text"]); + }); + + test("opposite settings cannot join the same pending gather", async () => { + let started!: () => void; + let release!: () => void; + const firstStarted = new Promise(resolve => { started = resolve; }); + const pending = new Promise(resolve => { release = resolve; }); + let calls = 0; + globalThis.fetch = (async () => { + if (++calls === 1) { started(); await pending; } + return modelsResponse(); + }) as typeof fetch; + const onPromise = gatherRoutedModels(config(true, true)); + await firstStarted; + const offPromise = gatherRoutedModels(config(false, true)); + release(); + const [on, off] = await Promise.all([onPromise, offPromise]); + expect(modalities(on, "no-vision")).toEqual(["text", "image"]); + expect(modalities(off, "no-vision")).toEqual(["text"]); + }); + + test("a pending gather keeps the enabled setting it admitted", async () => { + let started!: () => void; + let release!: () => void; + const firstStarted = new Promise(resolve => { started = resolve; }); + const pending = new Promise(resolve => { release = resolve; }); + globalThis.fetch = (async () => { started(); await pending; return modelsResponse(); }) as typeof fetch; + const fixture = config(true, true); + const result = gatherRoutedModels(fixture); + await firstStarted; + fixture.visionSidecar!.enabled = false; + release(); + expect(modalities(await result, "no-vision")).toEqual(["text", "image"]); + }); + + test("gather identity distinguishes disabled from the default enabled policy", () => { + expect(gatherFlightKey(config(false))).not.toBe(gatherFlightKey(config(true))); + expect(gatherFlightKey(config())).toBe(gatherFlightKey(config(true))); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 184668bd9da..2915f71da91 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -266,6 +266,7 @@ "catalog-seed-window-fill.test.ts": "codex-integration", "catalog-slug-uniqueness-boundary.test.ts": "codex-integration", "catalog-verbosity-default.test.ts": "codex-integration", + "catalog-vision-sidecar-disabled.test.ts": "codex-integration", "catalog-vision-sidecar-modalities.test.ts": "codex-integration", "catalog-zero-credit-picker.test.ts": "codex-integration", "chat-completions-deferred-tools.test.ts": "responses", "passthrough-deferred-tools.test.ts": "responses", "chat-completions-endpoint.test.ts": "responses", "chat-completions-pool-mode.test.ts": "responses", @@ -1110,6 +1111,7 @@ "shutdown-launcher.test.ts": "service", "sidebar-routes.test.ts": "server", "sidebar-star-state.test.ts": "server", "sidecar-abort.test.ts": "vision", "sidecar-auth.test.ts": "vision", "sidecar-candidates.test.ts": "vision", + "sidecar-settings-vision-catalog.test.ts": "vision", "sidecar-settings-vision-controls.test.ts": "vision", "sidecar-settings-vision-filter.test.ts": "vision", "sidecar-settings-web-search-gate.test.ts": "vision", "sidecar-settings-web-search-off.test.ts": "vision", "sidecar-settings-web-search-stream.test.ts": "vision", "sidecar-tracker.test.ts": "vision", diff --git a/tests/vision/sidecar-settings-vision-catalog.test.ts b/tests/vision/sidecar-settings-vision-catalog.test.ts new file mode 100644 index 00000000000..fb13b9c6ce2 --- /dev/null +++ b/tests/vision/sidecar-settings-vision-catalog.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { clearGatherRoutedModelsInflight, resetCatalogRuntimeStateForTests } from "../../src/codex/catalog"; +import { setBundledCatalogCacheForTests } from "../../src/codex/catalog/bundled"; +import type { CatalogDisposition } from "../../src/codex/convergence-types"; +import { projectCatalogOnlyOutcome } from "../../src/codex/management-convergence"; +import { clearModelCache } from "../../src/codex/model-cache"; +import { resetCodexModelEntitlementCacheForTests } from "../../src/codex/model-entitlements"; +import { resetCodexRuntimeResolveCacheForTests, setCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime"; +import { resetSiblingStartForTests } from "../../src/codex/sibling-start"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { ManagementApiDeps } from "../../src/server/management/context"; +import type { OcxConfig } from "../../src/types"; +import { ManagementRequest } from "../helpers/management-auth"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +let home: TempHome; +let previousCli: string | undefined; +const originalFetch = globalThis.fetch; +const native = { slug: "gpt-5.5", display_name: "Native", priority: 1, visibility: "list", + base_instructions: "You are Codex.", supported_reasoning_levels: [{ effort: "medium", description: "Medium" }] }; + +/** A static text-only route needs no live credential or provider discovery. */ +function fixture(enabled?: boolean): OcxConfig { + return { port: 10100, defaultProvider: "vision-settings-fixture", providers: { + "vision-settings-fixture": { adapter: "openai-chat", baseUrl: "https://vision-settings.example/v1", + apiKey: "fixture-key", authMode: "key", liveModels: false, models: ["planner"], + modelInputModalities: { planner: ["text"] }, modelContextWindows: { planner: 200_000 } }, + }, ...(enabled === undefined ? {} : { visionSidecar: { enabled } }) }; +} + +/** Exercise the authenticated management dispatcher and its catalog-only callback. */ +async function put(config: OcxConfig, body: unknown, deps: ManagementApiDeps = {}): Promise { + const url = new URL("http://localhost/api/sidecar-settings"); + const response = await handleManagementAPI(new ManagementRequest(url, { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }), url, config, deps); + if (!response) throw new Error("Sidecar settings route was not handled."); + return response; +} + +beforeEach(() => { + home = createTempHome("ocx-vision-settings-catalog-"); + mkdirSync(home.codexHome); + previousCli = process.env.CODEX_CLI_PATH; + process.env.CODEX_CLI_PATH = home.path("must-not-execute"); + globalThis.fetch = (async () => { throw new Error("Unexpected upstream request."); }) as typeof fetch; + resetSiblingStartForTests(); + clearModelCache(); clearGatherRoutedModelsInflight(); resetCatalogRuntimeStateForTests(); + resetCodexModelEntitlementCacheForTests(); resetCodexRuntimeResolveCacheForTests(); + const runtime = { command: process.env.CODEX_CLI_PATH, version: "0.145.0", source: "fallback" as const }; + setCodexRuntimeResolveCacheForTests({ runtime, failures: [] }, { discoverAlternatives: false }); + setBundledCatalogCacheForTests(runtime, { models: [native] }); + writeFileSync(join(home.codexHome, "config.toml"), 'model_catalog_json = "opencodex-catalog.json"\n'); + writeFileSync(join(home.codexHome, "opencodex-catalog.json"), JSON.stringify({ models: [native] })); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache(); clearGatherRoutedModelsInflight(); resetCatalogRuntimeStateForTests(); + resetCodexModelEntitlementCacheForTests(); resetCodexRuntimeResolveCacheForTests(); resetSiblingStartForTests(); + if (previousCli === undefined) delete process.env.CODEX_CLI_PATH; + else process.env.CODEX_CLI_PATH = previousCli; + home.remove(); +}); + +test.each([[undefined, false], [true, false], [false, true]] as const)("an effective Vision change converges only after persistence: %j -> %j", async (before, enabled) => { + const config = fixture(before); + saveConfig(config); + let calls = 0; + const response = await put(config, { vision: { enabled } }, { + createManagementConvergeCodex: captured => async request => { + calls++; + expect(captured).toBe(config); + expect(request).toMatchObject({ scope: "catalog", reason: "management-mutation", deadlineMs: 1_000 }); + const saved = JSON.parse(readFileSync(home.path("config.json"), "utf8")) as OcxConfig; + expect(saved.visionSidecar?.enabled !== false).toBe(enabled); + return projectCatalogOnlyOutcome({ changed: true, + catalogRefresh: { status: "committed", changed: true, degraded: false, notices: [] } }); + }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ vision: { enabled }, catalogRefreshPending: false }); + expect(calls).toBe(1); +}); + +test.each([[undefined, true], [true, true], [false, false]] as const)("the same effective Vision setting does not converge: %j -> %j", async (before, enabled) => { + const response = await put(fixture(before), { vision: { enabled } }, { + createManagementConvergeCodex: () => { throw new Error("Unchanged setting must not converge."); }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ catalogRefreshPending: false }); +}); + +test("partial and invalid writes do not refresh the catalog", async () => { + const config = fixture(false); + let calls = 0; + const deps: ManagementApiDeps = { createManagementConvergeCodex: () => { + calls++; throw new Error("Unrelated writes must not converge."); + } }; + for (const body of [{ vision: { timeoutMs: 12_000 } }, { webSearch: { streamRoutedModelOutput: true } }]) { + expect((await put(config, body, deps)).status).toBe(200); + } + expect((await put(config, { vision: { enabled: "false" } }, deps)).status).toBe(400); + expect(calls).toBe(0); +}); + +test.each([ + { status: "skipped", reason: "busy", retryable: true }, + { status: "skipped", reason: "catalog-unavailable", retryable: false }, + { status: "failed", reason: "disk", phase: "commit", retryable: false, partialWrite: false }, +] satisfies CatalogDisposition[])("an incomplete catalog refresh is reported without undoing the saved switch: %j", async disposition => { + const response = await put(fixture(), { vision: { enabled: false } }, { + createManagementConvergeCodex: () => async () => projectCatalogOnlyOutcome({ changed: false, catalogRefresh: disposition }), + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ vision: { enabled: false }, catalogRefreshPending: true }); + expect(JSON.parse(readFileSync(home.path("config.json"), "utf8")).visionSidecar.enabled).toBe(false); +}); + +test("a live settings toggle publishes new modalities to both managed catalog and Codex cache", async () => { + const config = fixture(); + saveConfig(config); + const paths = ["opencodex-catalog.json", "models_cache.json"].map(name => join(home.codexHome, name)); + const stale = { models: [native, { slug: "vision-settings-fixture/planner", input_modalities: ["text", "image"] }] }; + for (const path of paths) writeFileSync(path, JSON.stringify(stale)); + for (const enabled of [false, true]) { + const response = await put(config, { vision: { enabled } }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ catalogRefreshPending: false }); + for (const path of paths) { + const rows = JSON.parse(readFileSync(path, "utf8")).models as Array<{ slug: string; input_modalities?: string[] }>; + expect(rows.find(row => row.slug === "vision-settings-fixture/planner")?.input_modalities) + .toEqual(enabled ? ["text", "image"] : ["text"]); + } + } +}); From 32fa859f73f1dfed82066791dffae264599e8658 Mon Sep 17 00:00:00 2001 From: Rafael Moreira Date: Fri, 16 Oct 2026 09:58:18 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20preferModelAliasIds=20=E2=80=94=20p?= =?UTF-8?q?ublish=20model=20aliases=20as=20the=20row=20id=20in=20/v1/model?= =?UTF-8?q?s=20(carry=20#7173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carried from #7173. Co-authored-by: Rafael Moreira --- .../docs/reference/configuration/routing.md | 10 + scripts/test-layout/layout.json | 2 +- src/cli/doctor.ts | 5 + src/config/schema/config-schema.ts | 1 + src/server/index/model-alias-id.ts | 36 +++ src/server/index/serve-options.ts | 10 +- src/types/config.ts | 15 ++ structure/catalog.md | 2 +- structure/config.md | 2 +- tests/fixtures/test-layout-expected.json | 2 +- tests/server/prefer-model-alias-ids.test.ts | 246 ++++++++++++++++++ 11 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 src/server/index/model-alias-id.ts create mode 100644 tests/server/prefer-model-alias-ids.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 0a299f7cf93..5d4c63ec543 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -13,6 +13,16 @@ Routing turns the model id sent by a client into one concrete provider and upstr | `combos?` | `Record` | `{}` | Virtual `combo/` models built from ordered provider/model targets. | | `routingProfiles?` | `Record` | `{}` | Virtual `policy/` models that select among an explicit candidate allowlist using hard capability requirements and deterministic scoring. | +### Model alias discovery + +Set `preferModelAliasIds: true` to publish configured or enabled built-in model aliases +as the `id` in `GET /v1/models`. The default is `false`. Promoted rows retain their +previous qualified ID in `native_id` and keep `alias_of`; both IDs remain routable. +Models without aliases keep their existing IDs. If a short alias is ambiguous across +providers or is shadowed by another route, the row keeps its qualified ID so selecting +it still reaches the intended model. The qualified ID also stays when a built-in short +alias would produce an unparseable synthetic Fast selector. Codex's own model catalog is unchanged. + ### Codex Pool low-quota protection `codexPool.lowQuotaProtection` applies only to stored Codex Pool accounts. The Desktop/main diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index cf38cdac454..3d2ddb741f4 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json"], - "explicit": { "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", + "explicit": { "prefer-model-alias-ids.test.ts": "server", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "owner-registry-isolation.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 01c53522a53..52f092be756 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1160,6 +1160,11 @@ async function fetchExposedModelIds(live: LiveProxy, fetchFn: ExposedModelsFetch if (id.length === 0) continue; if (id.length > EXPOSED_MODEL_ID_MAX_LENGTH) return null; ids.add(id); + const nativeId = (row as { native_id?: unknown }).native_id; + if (nativeId !== undefined) { + if (typeof nativeId !== "string" || nativeId.length > EXPOSED_MODEL_ID_MAX_LENGTH) return null; + if (nativeId.length > 0) ids.add(nativeId); + } } return ids; } catch { diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index e53f721e76a..abe9d3f3a57 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -210,6 +210,7 @@ export const configSchema = z.object({ .catch(undefined), defaultProvider: z.string().min(1).default("openai"), defaultModelAliases: z.boolean().optional(), + preferModelAliasIds: z.boolean().optional().catch(false), // Malformed hand edits disable this opt-in projection without rejecting providers. cursorEffortRows: z.boolean().optional().catch(false), // Fast selectors default on; malformed hand edits disable them without rejecting providers. diff --git a/src/server/index/model-alias-id.ts b/src/server/index/model-alias-id.ts new file mode 100644 index 00000000000..e4204f53a50 --- /dev/null +++ b/src/server/index/model-alias-id.ts @@ -0,0 +1,36 @@ +import { fastRowBases } from "../fast-row"; +import { previewRouteModel } from "../../router"; +import type { OcxConfig } from "../../types"; + +/** + * Build the alias promoter for one model listing. The Fast-base inventory walks + * the configured catalog, so it is computed at most once per listing instead of + * once per aliased row. + */ +export function modelAliasIdPromoter(config: OcxConfig) { + let isFastBase: ((id: string) => boolean) | undefined; + const fastBases = () => (isFastBase ??= fastRowBases(config)); + return (provider: string, model: string, alias: string, fastEligible: boolean) => + preferredModelAliasId(config, provider, model, alias, fastEligible, fastBases); +} + +/** Promote only a short alias whose ordinary route is this exact upstream model. */ +export function preferredModelAliasId( + config: OcxConfig, provider: string, model: string, alias: string, fastEligible: boolean, + fastBases: () => (id: string) => boolean = () => fastRowBases(config), +): string | undefined { + if (config.preferModelAliasIds !== true) return undefined; + // Built-in bare aliases are not stable Fast bases. Preserve the qualified ID + // when promotion would publish a synthetic sibling that ingress cannot parse. + if (config.fastRows !== false && fastEligible && !fastBases()(alias)) return undefined; + try { + // Preview cannot consume a combo's round-robin selection state. A policy/combo, + // reserved native name or another provider's native id must keep its own meaning. + const route = previewRouteModel(config, alias); + return route.routeReason === "model-alias" && route.providerName === provider && route.modelId === model + ? alias : undefined; + } catch { + // An ambiguous alias stays available through the existing qualified public id. + return undefined; + } +} diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 0159c536f8b..72af800ba1a 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -1,4 +1,5 @@ import type { Server, ServerWebSocket } from "bun"; +import { modelAliasIdPromoter } from "./model-alias-id"; import type { StartServerDeps } from "./startup-warnings"; import { isLinkManagementPath } from "../../link/management-routes"; import { @@ -1252,6 +1253,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { effortRowKnownIds, )); }; + const promoteAliasId = modelAliasIdPromoter(config); const routedRows = await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered) .filter(m => listAllows(m.provider, m.id)) .map(async m => { @@ -1269,8 +1271,11 @@ export function createServeOptions(ctx: ServeOptionsContext) { knownModelIdsForProvider(m.provider, provider, config), ).get(m.id) : undefined; + const effectiveId = !isCombo && !fastModelId && effective + ? promoteAliasId(m.provider, m.id, effective.alias, catalogRowFastEligible(m)) ?? publicId + : publicId; const row = { - id: publicId, + id: effectiveId, object: "model", created: 0, // This endpoint is an OpenAI-compatible inbound contract. Some clients use @@ -1279,6 +1284,9 @@ export function createServeOptions(ctx: ServeOptionsContext) { owned_by: isCombo ? "openai" : (m.owned_by ?? m.provider), ...(isCombo ? { is_combo: true } : {}), ...(effective ? { alias_of: `${provider?.alias || m.provider}/${effective.alias}` } : {}), + // When preferModelAliasIds promotes the alias to `id`, retain the original + // provider/nativeId so clients that need the upstream identity can read it. + ...(effectiveId !== publicId ? { native_id: publicId } : {}), ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort), ...modelCapabilityFields({ reasoningEfforts: m.reasoningEfforts, diff --git a/src/types/config.ts b/src/types/config.ts index c5d86ac2e89..7fd610d2d75 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -575,6 +575,21 @@ export interface OcxConfig { }; /** Enable the shipped model alias patterns for providers without an override. */ defaultModelAliases?: boolean; + /** + * When `true`, the raw OpenAI-compatible `/v1/models` list publishes a safe alias (e.g. + * `SEK-glm-4.7-flash`) as the row `id` instead of `provider/nativeId` + * (e.g. `Seek/glm-4.7-flash`). The original `provider/nativeId` moves to a + * new `native_id` field so clients that need it can still read it. + * + * **Default: `false`** — the historical `provider/nativeId` form remains the + * published `id`, and the alias is in the informational `alias_of` field. + * + * Codex (`client_version`) and Anthropic-specific discovery retain their own + * identities and are unchanged. Ambiguous or shadowed short aliases retain the + * qualified ID, as do aliases with unparseable synthetic Fast siblings. + * Existing requests using `provider/nativeId` continue to work unchanged. + */ + preferModelAliasIds?: boolean; /** * Opt-in Cursor Private Inference compatibility rows. When true, `/v1/models` * adds `--` selectors for reasoning-capable model ids absent diff --git a/structure/catalog.md b/structure/catalog.md index 793731a8d0a..1e63d962121 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -295,7 +295,7 @@ a label edit refresh Codex output. Raw `/v1/models` rows advertise positive safe capacity values in both Cursor's nested `capabilities` object and top-level discovery fields used by other clients. A model with a larger -opt-in context tier uses that effective long window in both shapes; invalid values are omitted. For `github-copilot`, `modelContextTiers` raises a selected long-context window only when an exact `modelContextWindows` value supplies per-model evidence; that value wins before the provider cap, while unknown models retain observed metadata. The gather fingerprint includes the map. +opt-in context tier uses that effective long window in both shapes; invalid values are omitted. For `github-copilot`, `modelContextTiers` raises a selected long-context window only when an exact `modelContextWindows` value supplies per-model evidence; that value wins before the provider cap, while unknown models retain observed metadata. The gather fingerprint includes the map. `src/server/index/model-alias-id.ts` applies opt-in `preferModelAliasIds` to `/v1/models` only: short aliases are promoted only when a read-only route preview identifies the same upstream by `model-alias`; ambiguous or shadowed aliases and aliases with unparseable synthetic Fast siblings retain qualified IDs; Cursor Fast remaps also keep their advertised identity. Promoted rows keep their original ID in `native_id` and retain `alias_of`. Codex catalog gathering is unchanged. `src/cli/doctor.ts` includes retained `native_id` values in model-exposure checks. Supported bare native GPT rows also consume `providers.openai.modelDisplayNames`. Retained sync and convergence pass the same map to the observed-state merge. After native normalization and diff --git a/structure/config.md b/structure/config.md index a2bc0723340..9e00cabc247 100644 --- a/structure/config.md +++ b/structure/config.md @@ -28,7 +28,7 @@ the [source-owned credential contract](codex-home.md#orca-source-owned-account-i ## Config surface -Custom Responses providers can explicitly enable `preserveResponsesInputItemIds` and `preserveResponsesMessageMetadata`; both default off and accept only booleans. The [replay sanitization contract](transports/responses-wire-shapes.md) keeps ordinary destinations unchanged and preserves xAI custom-call ID repair. `src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. `src/config/schema/blocked-model-redirects.ts` applies the same read-degrade/write-reject boundary to malformed `blockedModelRedirects` maps. The [failure-only contract](transports/responses-failover.md) leaves provider identity, accounts and client compaction unchanged. Optional `desktopCompatibility: { startOnProxyStart: boolean }` likewise rejects invalid writes and degrades invalid disk values to off; it saves neither certificate consent nor correction mode. Its field-scoped dashboard writer adopts committed state into the live comparison baseline; see [Codex Desktop startup](clients/codex-desktop.md#proxy-startup-preference). `src/cli/config-command.ts` accepts one leading UTF-8 BOM when parsing validate/import input from a file or stdin. JSON syntax and schema validation still run before persistence; BOM characters inside string values remain data. +`src/config/schema/config-schema.ts` validates the optional `preferModelAliasIds` flag as a boolean; malformed disk values disable this raw-list projection without rejecting providers. It defaults off. Custom Responses providers can explicitly enable `preserveResponsesInputItemIds` and `preserveResponsesMessageMetadata`; both default off and accept only booleans. The [replay sanitization contract](transports/responses-wire-shapes.md) keeps ordinary destinations unchanged and preserves xAI custom-call ID repair. `src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. `src/config/schema/blocked-model-redirects.ts` applies the same read-degrade/write-reject boundary to malformed `blockedModelRedirects` maps. The [failure-only contract](transports/responses-failover.md) leaves provider identity, accounts and client compaction unchanged. Optional `desktopCompatibility: { startOnProxyStart: boolean }` likewise rejects invalid writes and degrades invalid disk values to off; it saves neither certificate consent nor correction mode. Its field-scoped dashboard writer adopts committed state into the live comparison baseline; see [Codex Desktop startup](clients/codex-desktop.md#proxy-startup-preference). `src/cli/config-command.ts` accepts one leading UTF-8 BOM when parsing validate/import input from a file or stdin. JSON syntax and schema validation still run before persistence; BOM characters inside string values remain data. `skills.catalog_refresh` in the proxy JSON configuration accepts `per_session` (the runtime default when absent) or `per_turn`. The former retains received skills instructions for a conversation; the latter passes through the current catalog. This is separate from Codex's `skills.include_instructions` TOML switch and does not change the live dashboard probe. See the [Responses snapshot contract](transports/responses.md#responses-httpsse). diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2915f71da91..14fa00ec972 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,5 @@ { - "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", + "prefer-model-alias-ids.test.ts": "server", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", diff --git a/tests/server/prefer-model-alias-ids.test.ts b/tests/server/prefer-model-alias-ids.test.ts new file mode 100644 index 00000000000..557a1e337de --- /dev/null +++ b/tests/server/prefer-model-alias-ids.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { parseFastRowId, fastRowBases } from "../../src/server/fast-row"; +import { knownEffortRowIds } from "../../src/server/effort-row"; +import { collectDefaultModelExposure } from "../../src/cli/doctor"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { routeModel } from "../../src/router"; +import { gatherRoutedModels } from "../../src/codex/catalog/provider-fetch"; +import { startServer } from "../../src/server"; +import type { OcxConfig } from "../../src/types"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * `preferModelAliasIds` changes the `/v1/models` row `id` from `provider/nativeId` to the + * effective model alias when one exists. This lets every downstream harness (Codex CLI, + * Command Code, Claude Code, Hermes) discover operator-chosen short names directly from the + * API, eliminating per-harness synchronization scripts. + * + * Each case starts a real server with `startServer(0)`, hits `GET /v1/models`, and inspects + * the JSON output — the same integration style used by Cursor local-models and Grok discovery. + */ +setDefaultTimeout(SERVER_BUDGET_MS); + +const previousHome = process.env.OPENCODEX_HOME; +let testHome = ""; + +function aliasConfig(prefer: boolean): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "seek", + ...(prefer ? { preferModelAliasIds: true } : {}), + providers: { + seek: { + adapter: "openai-chat", + baseUrl: "https://seek.example/v1", + alias: "SEK", + liveModels: false, + models: ["glm-4.7-flash"], + modelAliases: { + "glm-4.7-flash": "SEK-glm-4.7-flash", + }, + }, + }, + }; +} + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "ocx-prefer-alias-")); + process.env.OPENCODEX_HOME = testHome; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testHome) removeTreeWithRetry(testHome); + testHome = ""; +}); + +describe("preferModelAliasIds on /v1/models", () => { + test("default (off): row id is provider/nativeId, no native_id field", async () => { + saveConfig(aliasConfig(false)); + const server = startServer(0); + try { + const res = await fetch(new URL("/v1/models", server.url)); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { id: string; alias_of?: string; native_id?: string }[] }; + const seekRow = body.data.find(m => m.id.includes("glm") || m.id.includes("SEK")); + expect(seekRow).toBeDefined(); + // Without the flag, id is the traditional provider/nativeId form + expect(seekRow!.id).toContain("/"); + // native_id is only added when alias is promoted + expect(seekRow!.native_id).toBeUndefined(); + // alias_of is present because we have modelAliases + expect(seekRow!.alias_of).toBeDefined(); + } finally { + server.stop(true); + } + }); + + test("on: row id becomes the effective alias, native_id preserves original", async () => { + saveConfig(aliasConfig(true)); + const server = startServer(0); + try { + const res = await fetch(new URL("/v1/models", server.url)); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { id: string; alias_of?: string; native_id?: string }[] }; + const seekRow = body.data.find(m => m.id === "SEK-glm-4.7-flash"); + expect(seekRow).toBeDefined(); + // alias is now the id + expect(seekRow!.id).toBe("SEK-glm-4.7-flash"); + // native_id preserves the old form + expect(seekRow!.native_id).toBeDefined(); + expect(seekRow!.native_id).toContain("glm-4.7-flash"); + expect(seekRow!.native_id).toContain("/"); + // alias_of still present for backward compatibility + expect(seekRow!.alias_of).toBeDefined(); + } finally { + server.stop(true); + } + }); + + test("on: models without modelAliases keep their provider/nativeId as id", async () => { + const config = aliasConfig(true); + // Add a second provider without modelAliases + config.providers.plain = { + adapter: "openai-chat", + baseUrl: "https://plain.example/v1", + liveModels: false, + models: ["test-model"], + }; + saveConfig(config); + const server = startServer(0); + try { + const res = await fetch(new URL("/v1/models", server.url)); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { id: string; alias_of?: string; native_id?: string }[] }; + const plainRow = body.data.find(m => m.id.includes("test-model")); + expect(plainRow).toBeDefined(); + // No alias → id stays as provider/nativeId + expect(plainRow!.id).toBe("plain/test-model"); + // No native_id added when no alias swap happened + expect(plainRow!.native_id).toBeUndefined(); + } finally { + server.stop(true); + } + }); +}); + + +test("shared aliases remain qualified, unique and routable", async () => { + const config = aliasConfig(true); + config.providers.other = { ...config.providers.seek!, alias: "OTH" }; + saveConfig(config); + const server = startServer(0); + try { + const body = await (await fetch(new URL("/v1/models", server.url))).json() as { data: { id: string }[] }; + const ids = body.data.map(row => row.id); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).not.toContain("SEK-glm-4.7-flash"); + expect(ids).toContain("seek/glm-4.7-flash"); + expect(ids).toContain("other/glm-4.7-flash"); + for (const id of ids) expect(() => routeModel(config, id)).not.toThrow(); + } finally { server.stop(true); } +}); + +test("a shadowed alias never advertises a different provider's route", async () => { + const config = aliasConfig(true); + config.providers.seek!.modelAliases = { "glm-4.7-flash": "plain-native" }; + config.providers.plain = { adapter: "openai-chat", baseUrl: "https://plain.example/v1", liveModels: false, models: ["plain-native"] }; + saveConfig(config); + const server = startServer(0); + try { + const body = await (await fetch(new URL("/v1/models", server.url))).json() as { data: { id: string; owned_by: string }[] }; + const row = body.data.find(row => row.owned_by === "seek")!; + expect(routeModel(config, row.id)).toMatchObject({ providerName: "seek", modelId: "glm-4.7-flash" }); + } finally { server.stop(true); } +}); + +test("preferred and native IDs share a route; Codex gather is unchanged", async () => { + const config = aliasConfig(true); + expect(routeModel(config, "SEK-glm-4.7-flash")).toMatchObject({ providerName: "seek", modelId: "glm-4.7-flash" }); + expect(routeModel(config, "seek/glm-4.7-flash")).toMatchObject({ providerName: "seek", modelId: "glm-4.7-flash" }); + const before = await gatherRoutedModels({ ...config, preferModelAliasIds: false }); + const after = await gatherRoutedModels(config); + expect(after.map(m => [m.id, m.alias])).toEqual(before.map(m => [m.id, m.alias])); + expect(after.filter(m => m.provider === "seek")).toHaveLength(1); +}); + + +test("built-in aliases preserve routable Fast siblings", async () => { + const config = aliasConfig(true); + config.defaultModelAliases = true; + config.providers.seek!.models = ["claude-haiku-4-5"]; + config.providers.seek!.modelAliases = {}; + config.providers.seek!.supportsServiceTier = true; + saveConfig(config); + const server = startServer(0); + try { + const body = await (await fetch(new URL("/v1/models", server.url))).json() as { data: { id: string }[] }; + const fast = body.data.filter(row => row.id.endsWith("--fast")); + expect(fast).toHaveLength(1); + for (const row of fast) { + const parsed = parseFastRowId(row.id, config, knownEffortRowIds(config), fastRowBases(config)); + expect(parsed).not.toBeNull(); + expect(routeModel(config, parsed!.baseId)).toMatchObject({ providerName: "seek", modelId: "claude-haiku-4-5" }); + } + } finally { server.stop(true); } +}); + + +test("Codex and Anthropic discovery retain one unchanged identity per model", async () => { + const snapshots: string[][][] = []; + for (const prefer of [false, true]) { + saveConfig(aliasConfig(prefer)); + const server = startServer(0); + try { + const codex = await (await fetch(new URL("/v1/models?client_version=0.130.0", server.url))).json() as { models: { slug: string }[] }; + const anthropic = await (await fetch(new URL("/v1/models", server.url), { headers: { "anthropic-version": "2023-06-01", "user-agent": "claude-code/2.1.207" } })).json() as { data: { id: string }[] }; + const ids = [codex.models.map(row => row.slug), anthropic.data.map(row => row.id)]; + for (const list of ids) { + expect(list.length).toBeGreaterThan(0); + expect(new Set(list).size).toBe(list.length); + } + snapshots.push(ids.map(list => list.toSorted())); + } finally { server.stop(true); } + } + expect(snapshots[1]).toEqual(snapshots[0]); +}); + + +test("Cursor Fast listing keeps the advertised Fast identity", async () => { + const config = aliasConfig(true); + config.fastMode = true; + config.defaultProvider = "cursor"; + config.providers = { cursor: { adapter: "openai-chat", baseUrl: "https://cursor.example/v1", liveModels: false, models: ["claude-opus-5"], modelAliases: { "claude-opus-5": "opus-pick" } } }; + saveConfig(config); + const server = startServer(0); + try { + const body = await (await fetch(new URL("/v1/models", server.url))).json() as { data: { id: string; native_id?: string }[] }; + expect(body.data.map(row => row.id)).toContain("cursor/claude-opus-5-thinking-fast"); + expect(body.data.map(row => row.id)).not.toContain("opus-pick"); + } finally { server.stop(true); } +}); + +test("the config schema validates the alias-ID opt-in independently", () => { + const config = { ...aliasConfig(true), port: 18276 }; + expect(configSchema.parse(config).preferModelAliasIds).toBe(true); + expect(configSchema.parse({ ...config, preferModelAliasIds: false }).preferModelAliasIds).toBe(false); + expect(configSchema.parse({ ...config, preferModelAliasIds: "true" }).preferModelAliasIds).toBe(false); +}); + +test("doctor recognizes the native ID retained on an alias row", async () => { + const result = await collectDefaultModelExposure({ + readConfiguredModelFn: () => "seek/glm-4.7-flash", + readCatalogModelsFn: () => null, + live: { pid: 4321, port: 18277, source: "config" }, + fetchFn: async () => new Response(JSON.stringify({ data: [{ id: "SEK-glm-4.7-flash", native_id: "seek/glm-4.7-flash" }] })), + }); + expect(result.status).toBe("exposed"); + expect(result.source).toBe("proxy"); +}); From aebbf0f719744ea50d6b2060facd40aa206493fe Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:58:59 +0900 Subject: [PATCH 4/7] feat(catalog): global auto-review override and Models settings panel tidy (carry #6867) Carried from #6867. Co-authored-by: sechmachine <97589681+sechmachine727@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 20 +- .../docs/reference/configuration/providers.md | 14 +- gui/src/i18n/de.ts | 3 + gui/src/i18n/en.ts | 3 + gui/src/i18n/fr.ts | 3 + gui/src/i18n/ja.ts | 3 + gui/src/i18n/ko.ts | 3 + gui/src/i18n/pt.ts | 3 + gui/src/i18n/ru.ts | 3 + gui/src/i18n/tr.ts | 3 + gui/src/i18n/vi.ts | 3 + gui/src/i18n/zh-TW.ts | 3 + gui/src/i18n/zh.ts | 3 + gui/src/pages/Models.tsx | 75 +++-- gui/src/pages/models-settings-panel.tsx | 25 ++ gui/src/pages/models-settings-summary.ts | 6 + gui/src/pages/models-shared.ts | 27 ++ gui/src/pages/use-auto-review-settings.ts | 110 ++++++++ gui/src/styles-models-workspace.css | 128 +++------ .../models-auto-review-override.test.tsx | 65 +++++ .../models-auto-review-settings.test.tsx | 263 ++++++++++++++++++ scripts/test-layout/layout.json | 2 +- .../ocx/references/01_management_surface.md | 14 +- .../references/01_surface_providers-models.md | 41 ++- src/cli/capabilities-provider-models.ts | 11 + src/cli/models-runtime-subcommands.ts | 1 + src/cli/models-runtime.ts | 41 ++- src/codex/catalog/auto-review.ts | 23 +- src/config/load-degrade.ts | 6 + src/config/provider-validation.ts | 25 +- src/config/schema/config-schema.ts | 4 + src/config/schema/leaf-validators.ts | 8 + src/server/management/config-routes.ts | 35 +++ src/server/management/route-registry.ts | 2 + src/types/config.ts | 13 + structure/catalog.md | 2 +- structure/cli-management.md | 2 + structure/config.md | 2 +- structure/gui-and-management-api.md | 2 +- .../exhaustive-deps-suppression.test.ts | 2 +- tests/cli/cli-headless-parity.test.ts | 1 + tests/cli/cli-models-set.test.ts | 76 ++++- .../catalog-auto-review-override.test.ts | 123 ++++++++ tests/config/config-load-degrade.test.ts | 18 ++ tests/fixtures/test-layout-expected.json | 1 + .../management-auto-review-auth.test.ts | 73 +++++ .../management-auto-review-settings.test.ts | 171 ++++++++++++ 47 files changed, 1313 insertions(+), 152 deletions(-) create mode 100644 gui/src/pages/use-auto-review-settings.ts create mode 100644 gui/tests/models-auto-review-override.test.tsx create mode 100644 gui/tests/models-auto-review-settings.test.tsx create mode 100644 tests/codex-integration/catalog-auto-review-override.test.ts create mode 100644 tests/server/management-auto-review-auth.test.ts create mode 100644 tests/server/management-auto-review-settings.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index af1854528e0..7290c6ebd09 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1513,8 +1513,17 @@ dashboard or `custom` in `ocx init` and enter the base URL. See the Codex asks a second model to review approval requests, and takes that reviewer from `auto_review_model_override` on the catalog row of the current turn's model. The root -`auto_review_model` in `$CODEX_HOME/config.toml` applies one reviewer to every row. To give a -routed provider its own — usually cheaper — reviewer, set the selector on that provider row in +`auto_review_model` in `$CODEX_HOME/config.toml` is Codex's fallback. The dashboard's global +**Auto-review override** in Model settings applies one reviewer to all catalog rows that do not +have a provider-scoped reviewer. It stores `autoReviewOverride` in `~/.opencodex/config.json`; the +setting must be enabled and name a model. An enabled setting with no model stays unapplied and +leaves the Codex fallback in effect. A catalog refresh is a separate state: after a save it can +stay pending, and the Models page reports it so you can retry with `ocx sync`. From the CLI, read +the setting with `ocx models auto-review status` or +change it with `ocx models auto-review set [--enabled ]`; use `-` to clear the +model. CLI output recommends `ocx sync` when a refresh can be retried. If a refresh fails, the +setting remains saved and the command exits 1. Resolve the failure before syncing. To give a routed +provider its own — usually cheaper — reviewer, set the selector on that provider row in `~/.opencodex/config.json`: ```json @@ -1546,9 +1555,10 @@ no per-model override, leaving a valid provider-wide target as fallback. Whateve keep the root selector, or upstream behavior when that is unset. Removing the root selector leaves provider stamps alone, and removing a provider selector clears only that provider's stamps. -These fields are available through configuration, `PATCH /api/providers?name=`, and -the dashboard raw JSON provider editor; dedicated form controls are not present. The canonical `openai` provider -rejects them. Field-by-field rules live in the +Provider fields are available through configuration, `PATCH /api/providers?name=`, and +the dashboard raw JSON provider editor; dedicated provider form controls are not present. The +global override is available in Model settings. The canonical `openai` provider rejects provider +reviewer fields. Field-by-field rules live in the [provider configuration reference](/reference/configuration/providers/#auto-review-approval-model-selection). ## Rate limits in the providers overview diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 289a9e5ae1d..aa653b09f1f 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -558,9 +558,17 @@ optional pin in a hand-edited file is ignored on load without discarding the res Codex reads `auto_review_model_override` from the catalog row of the current turn's model to choose the model that reviews approval requests. The root `auto_review_model` setting in -`$CODEX_HOME/config.toml` applies one reviewer to task catalog rows; the provider-scoped fields -below override it per provider. The [provider guide](/guides/providers/#approval-reviewer-per-provider) -has the operator workflow and a worked example. +`$CODEX_HOME/config.toml` supplies Codex's fallback. The dashboard's global **Auto-review override** +in Model settings stores `autoReviewOverride` in `~/.opencodex/config.json` and takes precedence +over that fallback; the provider-scoped fields below still win on their own rows. The global setting +must be enabled and name a model. An enabled setting with no model stays unapplied and leaves the +Codex fallback in effect. A catalog refresh is a separate state: after a save it can stay pending, +and the Models page reports it so you can retry with `ocx sync`. CLI users can read the setting with +`ocx models auto-review status` or change it with +`ocx models auto-review set [--enabled ]`; use `-` to clear the model. CLI output +recommends `ocx sync` when a refresh can be retried. If a refresh fails, the setting remains saved +and the command exits 1. Resolve the failure before syncing. The [provider +guide](/guides/providers/#approval-reviewer-per-provider) has the operator workflow and a worked example. When native OpenAI rows are included and the final catalog has an ordinary bare native row other than Reserve (hidden rows count), OpenCodex keeps the hidden `codex-auto-review` row so diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 53a46fee97d..7b6a4e85116 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -515,6 +515,9 @@ export const de: Record = { "dash.shadowCallTooltip": "Die Codex-App ruft im Hintergrund ein Hilfsmodell für Titelgenerierung, Commit-Nachrichten und Skill-Orchestrierung auf. Das Modell wechselt zwischen Client-Versionen, daher fängt opencodex diesen Satz ab: {models}.", "models.shadowCallIntercept": "Shadow-Call-Abfangen", "models.shadowCallInterceptHint": "Fängt die Hintergrund-Hilfsaufrufe der Codex-App ({models}) ab und leitet sie an das gewählte Modell um.", + "models.autoReviewOverride": "Auto-Review-Überschreibung", + "models.autoReviewOverrideHint": "Legt fest, welches Modell Codex für die automatische Prüfung (Approval-Reviewer) verwendet. Wird beim Katalog-Abgleich auf alle Zeilen angewendet; ein autoReviewModel auf Anbieterebene hat weiterhin Vorrang.", + "models.autoReviewPending": "Ausstehend", "dash.sidecarBackend": "Backend", "dash.sidecarModel": "Modell", "dash.backendAuto": "Automatisch", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index dd45d9ef3f7..d8f4c64fa93 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -533,6 +533,9 @@ export const en = { "dash.shadowCallTooltip": "Codex App makes background helper calls for thread title generation, commit message generation, and skill orchestration. The helper model changed across client versions, so opencodex intercepts every model in this set: {models}. Enable this to redirect those calls to your chosen model.", "models.shadowCallIntercept": "Shadow Call Intercept", "models.shadowCallInterceptHint": "Intercepts Codex App's background helper calls ({models}) for titles and commit messages and redirects them to your chosen model.", + "models.autoReviewOverride": "Auto-review override", + "models.autoReviewOverrideHint": "Chooses the model Codex uses for auto-review (the approval reviewer). Applied to every catalog row at sync; a provider-scoped autoReviewModel still takes precedence on its own rows.", + "models.autoReviewPending": "Pending", "dash.sidecarBackend": "Backend", "dash.sidecarModel": "Model", "dash.backendAuto": "Auto", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index f913bf03e23..15034982db2 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -523,6 +523,9 @@ export const fr: Record = { "dash.shadowCallTooltip": "L’application Codex effectue des appels auxiliaires en arrière-plan pour générer les titres de fils, les messages de commit et orchestrer les compétences. Le modèle auxiliaire ayant changé selon les versions du client, opencodex intercepte tous les modèles de cet ensemble : {models}. Activez cette option pour rediriger ces appels vers le modèle choisi.", "models.shadowCallIntercept": "Interception des appels fantômes", "models.shadowCallInterceptHint": "Intercepte les appels auxiliaires en arrière-plan de l’application Codex ({models}) pour les titres et les messages de commit, puis les redirige vers le modèle choisi.", + "models.autoReviewOverride": "Remplacement de la revue automatique", + "models.autoReviewOverrideHint": "Choisit le modèle que Codex utilise pour la revue automatique (le modèle de revue des demandes d'approbation). Appliqué à chaque ligne du catalogue à la synchronisation ; un autoReviewModel défini au niveau du fournisseur reste prioritaire sur ses lignes.", + "models.autoReviewPending": "En attente", "dash.sidecarBackend": "Moteur", "dash.sidecarModel": "Modèle", "dash.backendAuto": "Auto", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3dde661880a..4b9cc48cb33 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -524,6 +524,9 @@ export const ja: Record = { "dash.shadowCallTooltip": "Codex App はスレッドタイトル生成、コミットメッセージ生成、スキルオーケストレーションをバックグラウンドで呼び出します。使われるモデルはクライアントのバージョンによって変わるため、opencodex は {models} をまとめて傍受します。これをオンにすると、それらの呼び出しを選択したモデルにリダイレクトします。", "models.shadowCallIntercept": "シャドウコール傍受", "models.shadowCallInterceptHint": "Codex App のバックグラウンドヘルパー呼び出し({models}: タイトル、コミットメッセージ)を傍受し、選択したモデルにリダイレクトします。", + "models.autoReviewOverride": "自動レビューの上書き", + "models.autoReviewOverrideHint": "Codex の自動レビュー(承認レビュアー)が使うモデルを指定します。カタログ同期時にすべての行へ適用され、プロバイダー固有の autoReviewModel がその行では引き続き優先されます。", + "models.autoReviewPending": "保留中", "dash.sidecarBackend": "バックエンド", "dash.sidecarModel": "モデル", "dash.backendAuto": "自動", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 44652dd9c54..96236cd01b2 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -519,6 +519,9 @@ export const ko: Record = { "dash.shadowCallTooltip": "Codex 앱은 스레드 제목 자동 생성, 커밋 메시지 생성, 스킬 오케스트레이션 같은 내부 작업을 백그라운드로 호출합니다. 이때 쓰는 모델은 클라이언트 버전마다 달라서 opencodex는 {models}를 모두 가로챕니다. 이 설정을 켜면 해당 호출이 선택한 모델로 넘어갑니다.", "models.shadowCallIntercept": "쉐도우 호출 가로채기", "models.shadowCallInterceptHint": "Codex 앱의 백그라운드 호출({models}, 제목·커밋 메시지)을 가로채 선택한 모델로 바꿉니다.", + "models.autoReviewOverride": "자동 검토 재정의", + "models.autoReviewOverrideHint": "Codex의 자동 검토(승인 검토자)에 사용할 모델을 지정합니다. 카탈로그 동기화 시 모든 행에 적용되며, 공급자별 autoReviewModel이 해당 행에서 계속 우선합니다.", + "models.autoReviewPending": "보류 중", "dash.sidecarBackend": "백엔드", "dash.sidecarModel": "모델", "dash.backendAuto": "자동", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 39477bb4852..114977a7e8b 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -537,6 +537,9 @@ export const pt: Record = { "dash.shadowCallTooltip": "O Codex App faz chamadas auxiliares em segundo plano para geração de título de thread, geração de mensagem de commit e orquestração de skills. O modelo auxiliar mudou entre versões do cliente, então o opencodex intercepta todos os modelos deste conjunto: {models}. Ative para redirecionar essas chamadas ao modelo escolhido.", "models.shadowCallIntercept": "Shadow Call Intercept", "models.shadowCallInterceptHint": "Intercepta as chamadas auxiliares em segundo plano do Codex App ({models}) para títulos e mensagens de commit e as redireciona ao modelo escolhido.", + "models.autoReviewOverride": "Substituição da revisão automática", + "models.autoReviewOverrideHint": "Define o modelo que o Codex usa na revisão automática (revisor de aprovação). Aplicado a todas as linhas do catálogo na sincronização; um autoReviewModel no provedor continua tendo prioridade nas próprias linhas.", + "models.autoReviewPending": "Pendente", "dash.sidecarBackend": "Backend", "dash.sidecarModel": "Modelo", "dash.backendAuto": "Automático", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index bf5ac713e68..d68f362294f 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -524,6 +524,9 @@ export const ru: Record = { "dash.shadowCallTooltip": "Codex App в фоновом режиме вызывает служебную модель для генерации заголовков тредов, сообщений коммитов и оркестрации навыков. Эта модель менялась между версиями клиента, поэтому opencodex перехватывает весь набор: {models}. Включите функцию, чтобы перенаправлять такие вызовы на выбранную вами модель.", "models.shadowCallIntercept": "Перехват теневых вызовов", "models.shadowCallInterceptHint": "Перехватывает фоновые служебные вызовы Codex App ({models}: заголовки, сообщения коммитов) и перенаправляет их на выбранную вами модель.", + "models.autoReviewOverride": "Переопределение авто-проверки", + "models.autoReviewOverrideHint": "Задаёт модель, которую Codex использует для автоматической проверки (ревьюера подтверждений). Применяется ко всем строкам каталога при синхронизации; autoReviewModel на уровне провайдера по-прежнему имеет приоритет.", + "models.autoReviewPending": "Ожидание", "dash.sidecarBackend": "Бэкенд", "dash.sidecarModel": "Модель", "dash.backendAuto": "Авто", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 72e3c72180c..d8ba8fd31e3 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -525,6 +525,9 @@ export const tr: Record = { "dash.shadowCallTooltip": "Codex App arka planda başlık ve commit mesajı oluşturmak için yardımcı çağrılar yapar (orijinal {models}). Etkinleştirerek bunları seçtiğiniz modele yönlendirebilirsiniz.", "models.shadowCallIntercept": "Gölge Çağrı Yakalama", "models.shadowCallInterceptHint": "Codex App'in başlıklar ve commit mesajları için yaptığı arka plan çağrılarını ({models}) yakalar ve seçtiğiniz modele yönlendirir.", + "models.autoReviewOverride": "Otomatik inceleme geçersiz kılma", + "models.autoReviewOverrideHint": "Codex’in otomatik incelemesi (onay gözden geçiren) için kullanılacak modeli seçer. Katalog eşitlemesinde tüm satırlara uygulanır; sağlayıcıya özel autoReviewModel kendi satırlarında yine önceliklidir.", + "models.autoReviewPending": "Beklemede", "dash.sidecarBackend": "Arka uç", "dash.sidecarModel": "Model", "dash.backendAuto": "Otomatik", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index b492db00252..2715bd5db40 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -523,6 +523,9 @@ export const vi: Record = { "dash.shadowCallTooltip": "Ứng dụng Codex thực hiện các lệnh gọi helper ngầm để tạo tiêu đề chuỗi, tạo commit message và điều phối các kĩ năng. Helper model có thể thay đổi giữa các phiên bản client, do đó opencodex chặn mọi model trong tập hợp này: {models}. Hãy bật tính năng này để chuyển hướng các lệnh gọi đó đến model bạn chọn.", "models.shadowCallIntercept": "Shadow Call Intercept", "models.shadowCallInterceptHint": "Chặn các lệnh gọi helper nền ({models}) của ứng dụng Codex để tạo tiêu đề và commit messages, và chuyển hướng chúng đến model bạn chọn.", + "models.autoReviewOverride": "Ghi đè duyệt tự động", + "models.autoReviewOverrideHint": "Chọn model Codex dùng cho việc duyệt tự động (mô hình đánh giá phê duyệt). Áp dụng cho mọi hàng trong catalog khi đồng bộ; autoReviewModel ở cấp nhà cung cấp vẫn được ưu tiên trên các hàng đó.", + "models.autoReviewPending": "Đang chờ", "dash.sidecarBackend": "Backend", "dash.sidecarModel": "Model", "dash.backendAuto": "Tự động", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index aa8e6d5f8ac..9e5f598566c 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -400,6 +400,9 @@ export const zhTW: Record = { "dash.shadowCallTooltip": "Codex 應用會為執行緒標題生成、提交訊息生成與技能編排發出背景 helper 呼叫。helper 模型因客戶端版本而異,因此 opencodex 攔截此集合中的每個模型:{models}。啟用此選項可將這些呼叫重定向到您選擇的模型。", "models.shadowCallIntercept": "影子呼叫攔截", "models.shadowCallInterceptHint": "攔截 Codex 應用的背景 helper 呼叫({models})以生成標題與提交訊息,並將它們重定向到您選擇的模型。", + "models.autoReviewOverride": "自動審查覆寫", + "models.autoReviewOverrideHint": "指定 Codex 自動審查(核准審查模型)所使用的模型。同步型錄時套用到所有資料列;提供者層級的 autoReviewModel 在這些資料列上仍具優先權。", + "models.autoReviewPending": "待定", "dash.sidecarBackend": "後端", "dash.sidecarModel": "模型", "dash.backendAuto": "自動", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 1d24a0df5a6..19ef67114e7 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -519,6 +519,9 @@ export const zh: Record = { "dash.shadowCallTooltip": "Codex 应用会在后台调用辅助模型来生成线程标题、提交消息以及进行技能编排。该模型随客户端版本变化,因此 opencodex 会同时拦截这些模型:{models}。启用此选项可将这些调用重定向到您选择的模型。", "models.shadowCallIntercept": "影子调用拦截", "models.shadowCallInterceptHint": "拦截 Codex 应用的后台辅助调用({models})并重定向到所选模型。", + "models.autoReviewOverride": "自动审查覆盖", + "models.autoReviewOverrideHint": "指定 Codex 自动审查(审批审查模型)使用的模型。同步目录时应用于所有行;提供方级的 autoReviewModel 在这些行上仍然优先。", + "models.autoReviewPending": "待定", "dash.sidecarBackend": "后端", "dash.sidecarModel": "模型", "dash.backendAuto": "自动", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index e4f5104362b..48fef9c8b9b 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -78,6 +78,7 @@ import { freeOnlyInForce, modelPricingKnown, REASONING_EFFORT_LEVELS, + routedModelOptions, type ModelRow, type ProviderContextCapsResponse, type ShadowCallData, @@ -89,8 +90,9 @@ import { SUBAGENT_SURFACE_GUIDE_URL, readSubagentSurfaceAdvisory } from "../suba import { shadowCallModelOptions } from "./dashboard-shared"; import { shadowSourceModelBadge, shadowSourceModelLabel } from "./shadow-call-source"; import { ModelCatalogDelivery } from "./models-catalog-state"; -import { CustomModelsSummary, InfoHint, ModelsSettingsPanel } from "./models-settings-panel"; +import { CustomModelsSummary, InfoHint, ModelsSettingsPanel, SettingsRow } from "./models-settings-panel"; import { modelsSettingsSummary } from "./models-settings-summary"; +import { useAutoReviewSettings } from "./use-auto-review-settings"; type CachedModelsPage = { models: ModelRow[]; @@ -328,6 +330,13 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c setStatus(message); setFeedbackGen(g => g + 1); }, []); + const { data: autoReview, saving: autoReviewSaving, load: loadAutoReview, save: saveAutoReview } = + useAutoReviewSettings(apiBase, t, publishFeedback); + useEffect(() => { + if (catalogActive) return; + // A hidden mounted panel may still hold the previous server's reviewer settings. + void loadAutoReview(); + }, [apiBase, catalogActive, loadAutoReview]); // Transient action feedback as a fixed toast: appearing or auto-clearing it never shifts // the workspace below (the old inline Notice pushed the whole model grid down by its // height on every apply). The timer itself just clears the status again. @@ -481,6 +490,11 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c ); }, [models, shadowCall?.model, shadowCall?.sourceModels, shadowModelOptions]); + const autoReviewOptions = useMemo( + () => [{ value: "", label: "—" }, ...routedModelOptions(shadowModelOptions, models, autoReview?.model)], + [models, shadowModelOptions, autoReview?.model], + ); + const loadShadowCall = useCallback(async () => { const bounded = createBoundedFetch(15_000); try { @@ -736,6 +750,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c if (!catalogActive) return; const timeout = window.setTimeout(() => { void loadShadowCall(); + void loadAutoReview(); void loadV2(); // Preset previews belong to the same tab. Loaded once rather than polled: the rules are // shipped code and the catalog poll above already refreshes the rows they describe. @@ -761,7 +776,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c // gui/.oxlintrc.json (override) and gui/doctor.config.json (ignore.overrides). An in-file // react-doctor-disable comment was tried and removed - it changed nothing, and // react/react-compiler penalises a component for carrying suppressions at all. - }, [catalogActive, loadShadowCall, loadV2]); + }, [catalogActive, loadShadowCall, loadAutoReview, loadV2]); const groups = useMemo( () => buildProviderModelGroups(models, providers), @@ -1971,27 +1986,31 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c <>
{modelDiscovery && ( -
- {t("models.newPolicyGlobal")} + void saveModelDiscovery(modelDiscovery.policy === "off" ? "on" : "off")} label={t("models.newPolicyGlobal")} /> -
+ )} -
+ void setDefaultAliases(!aliases.defaults.global)} label={t("models.useDefaultAliasesGlobal")} /> -
-
- {t("models.shadowCallIntercept")} ⓘ + + ⓘ} busy={!shadowCall}> {t("models.shadowCallOriginal", { models: shadowSourceModelBadge(shadowCall?.sourceModels) })} void saveShadowCall({ enabled: !shadowCall?.enabled })} disabled={!shadowCall || shadowCallSaving} label={t("models.shadowCallIntercept")} />
void saveAutoReview({ model: v })} disabled={!autoReview || autoReviewSaving || !autoReview.enabled} label={t("models.autoReviewOverride")} /> +
+
{(v2Loading || v2) && ( -
- {t("models.v2Label")} +
{(["v1", "default", "v2"] as const).map(mode => ( -
+
)} {v2 && v2.multiAgentMode === "v2" && ( -
-
- {t("models.keepNativeOnV1")} - void setKeepNativeChatGptOnV1(!v2.keepNativeChatGptOnV1)} - disabled={v2Busy} - label={t("models.keepNativeOnV1")} - /> - - - - -
-
+ + void setKeepNativeChatGptOnV1(!v2.keepNativeChatGptOnV1)} + disabled={v2Busy} + label={t("models.keepNativeOnV1")} + /> + + + + + )} {pendingSurface && ( {controlsBlock} diff --git a/gui/src/pages/models-settings-panel.tsx b/gui/src/pages/models-settings-panel.tsx index f1bfb366a62..a790c94e12c 100644 --- a/gui/src/pages/models-settings-panel.tsx +++ b/gui/src/pages/models-settings-panel.tsx @@ -82,3 +82,28 @@ export function InfoHint({ text }: { text: string }) { ); } + +/** + * One row of the folded settings panel: label on the left, controls pinned to the right + * edge. Six rows share this shape, so the wrapper lives here rather than being repeated + * in Models.tsx where each copy drifts independently. + */ +export function SettingsRow({ label, hint, busy, muted = true, caption, children }: { + label: ReactNode; + hint?: ReactNode; + busy?: boolean; + muted?: boolean; + caption?: boolean; + children: ReactNode; +}) { + const labelClass = ["models-row-label", muted ? "muted" : "", caption ? "text-caption" : "text-control"].filter(Boolean).join(" "); + return ( +
+ + {label} + {hint ? <> {hint} : null} + +
{children}
+
+ ); +} diff --git a/gui/src/pages/models-settings-summary.ts b/gui/src/pages/models-settings-summary.ts index 2e6d78f3856..e417896913c 100644 --- a/gui/src/pages/models-settings-summary.ts +++ b/gui/src/pages/models-settings-summary.ts @@ -43,6 +43,9 @@ export interface ModelsSettingsState { keepNativeOnV1?: boolean; shadowEnabled: boolean; shadowModel?: string; + /** Global auto-review override; folded whenever enabled, a blank model reading as pending. */ + autoReviewEnabled?: boolean; + autoReviewModel?: string; windowOn: boolean; windowValue: number; /** The SAVED picker mode — an unapplied draft is not the catalog's state. */ @@ -81,6 +84,9 @@ export function modelsSettingsSummary(t: TFn, state: ModelsSettingsState): Setti label: t("models.shadowCallIntercept"), value: state.shadowEnabled && state.shadowModel ? state.shadowModel : off, }); + if (state.autoReviewEnabled) { + items.push({ id: "auto-review", label: t("models.autoReviewOverride"), value: state.autoReviewModel || t("models.autoReviewPending") }); + } items.push({ id: "window", label: t("models.contextCapLabel"), value: state.windowOn ? fmtK(state.windowValue) : off }); const pickerKey = PICKER_MODE_KEYS[state.pickerMode]; items.push({ id: "order", label: t("models.pickerOrder.label"), value: pickerKey ? t(pickerKey) : state.pickerMode }); diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index c3896f3a340..bbbd5cabfb6 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -168,6 +168,12 @@ export interface ShadowCallData { sourceModels?: string[]; } +/** The global auto-review (approval reviewer) override the Models settings panel writes. */ +export interface AutoReviewData { + enabled: boolean; + model: string; +} + export const CAP_OPTIONS = Array.from({ length: 18 }, (_, i) => 100_000 + i * 50_000); // 100k … 950k export const CAP_OPTION_SET = new Set(CAP_OPTIONS); /** @@ -248,6 +254,27 @@ export function activeModelOptions( return options; } +/** + * Narrow the catalog options to the routed rows an auto-review override may name. + * + * The reviewer selector has to resolve to a provider-namespaced catalog row; native rows are + * dropped because Codex's own default already covers them and choosing one would pin the + * subscription model the override exists to avoid. A value saved before its row left the list + * stays selectable, or the control would read back blank and the next save would erase it. + */ +export function routedModelOptions( + visible: readonly { value: string; label: string }[], + models: readonly ModelRow[], + saved?: string, +): { value: string; label: string }[] { + const native = new Set(models.filter(model => model.native === true).map(model => model.namespaced)); + const options = visible.filter(option => !native.has(option.value)); + if (saved && !options.some(option => option.value === saved)) { + options.push({ value: saved, label: visible.find(option => option.value === saved)?.label ?? saved }); + } + return options; +} + /** `null` = no preference yet → caller should default to all groups collapsed. */ export function readCollapsedProviders(storage: StorageLike = localStorage): Set | null { try { diff --git a/gui/src/pages/use-auto-review-settings.ts b/gui/src/pages/use-auto-review-settings.ts new file mode 100644 index 00000000000..75215274080 --- /dev/null +++ b/gui/src/pages/use-auto-review-settings.ts @@ -0,0 +1,110 @@ +import { useCallback, useLayoutEffect, useRef, useState } from "react"; +import { createBoundedFetch, type BoundedFetch } from "../bounded-fetch"; +import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; +import type { TFn } from "../i18n/shared"; +import type { AutoReviewData } from "./models-shared"; + +export interface AutoReviewSettingsController { + data: AutoReviewData | null; + saving: boolean; + load: (options?: { reconcile?: boolean }) => Promise; + save: (patch: Partial) => Promise; +} + +export function useAutoReviewSettings( + apiBase: string, + t: TFn, + publishFeedback: (ok: boolean, message: string) => void, +): AutoReviewSettingsController { + const [state, setState] = useState<{ apiBase: string; data: AutoReviewData | null; saving: boolean }>({ + apiBase, data: null, saving: false, + }); + const data = state.apiBase === apiBase ? state.data : null; + const saving = state.apiBase === apiBase && state.saving; + const requests = useRef<{ read: BoundedFetch | null; write: BoundedFetch | null; generation: number; apiBase: string }>({ + read: null, write: null, generation: 0, apiBase, + }); + + useLayoutEffect(() => { + const scope = requests.current; + scope.apiBase = apiBase; + scope.generation++; + return () => { + scope.generation++; + scope.read?.controller.abort(); + scope.read?.clear(); + scope.read = null; + scope.write?.controller.abort(); + scope.write?.clear(); + scope.write = null; + }; + }, [apiBase]); + + const load = useCallback(async ({ reconcile = false }: { reconcile?: boolean } = {}) => { + const scope = requests.current; + if (scope.write && !reconcile) return null; + scope.read?.controller.abort(); + scope.read?.clear(); + const bounded = createBoundedFetch(15_000); + const generation = scope.generation; + scope.read = bounded; + try { + const response = await fetch(`${apiBase}/api/auto-review-settings`, { signal: bounded.signal }); + const next = await readJsonIfOk(response); + if (!next || typeof next.enabled !== "boolean" || typeof next.model !== "string") return null; + if (!bounded.signal.aborted && scope.apiBase === apiBase && scope.generation === generation && scope.read === bounded + && (!scope.write || reconcile)) { + setState({ apiBase, data: next, saving: scope.write !== null }); + return next; + } + return null; + } catch { /* old server / network: keep the row disabled */ return null; } + finally { + bounded.clear(); + if (scope.read === bounded) scope.read = null; + } + }, [apiBase]); + + const save = useCallback(async (patch: Partial) => { + const scope = requests.current; + if (!data || saving || scope.apiBase !== apiBase || scope.write) return; + const confirmed = data; + const bounded = createBoundedFetch(15_000); + scope.write = bounded; + const generation = ++scope.generation; + scope.read?.controller.abort(); + scope.read?.clear(); + scope.read = null; + setState({ apiBase, data: { ...confirmed, ...patch }, saving: true }); + try { + const response = await fetch(`${apiBase}/api/auto-review-settings`, { + method: "PUT", headers: { "Content-Type": "application/json" }, signal: bounded.signal, + body: JSON.stringify(patch), + }); + const next = await readJsonOrThrow(response, t("models.saveFailed")); + if (!next || next.ok !== true || typeof next.enabled !== "boolean" || typeof next.model !== "string") { + throw new Error(t("models.saveFailed")); + } + if (scope.apiBase !== apiBase || scope.generation !== generation) return; + setState({ apiBase, data: { enabled: next.enabled, model: next.model }, saving: true }); + if (!next.catalogRefresh || next.catalogRefresh.status !== "committed") { + publishFeedback(false, t("codexAuth.catalogRefreshPending")); + } + } catch (error) { + if (scope.apiBase === apiBase && scope.generation === generation) { + const refreshed = await load({ reconcile: true }); + if (scope.apiBase !== apiBase || scope.generation !== generation) return; + setState({ apiBase, data: refreshed ?? confirmed, saving: true }); + publishFeedback(false, error instanceof Error && error.message ? error.message : t("models.saveFailed")); + } + } finally { + bounded.clear(); + if (scope.write === bounded) scope.write = null; + if (scope.apiBase === apiBase && scope.generation === generation) { + setState(current => current.apiBase === apiBase ? { ...current, saving: false } : current); + } + } + }, [apiBase, data, saving, t, publishFeedback, load]); + + return { data, saving, load, save }; +} diff --git a/gui/src/styles-models-workspace.css b/gui/src/styles-models-workspace.css index faa06b6f8d6..d1272e36d2a 100644 --- a/gui/src/styles-models-workspace.css +++ b/gui/src/styles-models-workspace.css @@ -161,28 +161,47 @@ min-width: 0; } -/* Controls row: shadow call on the left, sub-agent mode pinned right. - Right group does not move when the model name length changes. */ +/* + The settings panel is one stack of uniform rows: each row is a label on the + left and its control cluster on the right, so every switch and picker lines up + on the same right edge no matter how long a model name or hint is. Rows span + the full panel width rather than pairing into columns, which kept the two + model pickers at ragged widths and left the sub-agent chips floating. +*/ .models-control-top-row { display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - column-gap: clamp(var(--space-3), 2vw, var(--space-6)); - row-gap: var(--space-2); + grid-template-columns: minmax(0, 1fr); + row-gap: var(--space-1); margin-bottom: var(--space-3); min-width: 0; } -.models-shadow-row { +/* One labelled control: label group left, action group right, both vertically centered. */ +.models-control-row { display: flex; align-items: center; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; + min-width: 0; + min-height: 32px; +} + +.models-row-label { + display: inline-flex; + align-items: center; gap: var(--space-2); min-width: 0; - flex-wrap: nowrap; } -.models-shadow-label { - white-space: nowrap; +.models-row-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + flex-wrap: wrap; + flex: 0 1 auto; + min-width: 0; } .models-shadow-warning { @@ -215,18 +234,6 @@ white-space: nowrap; } -.models-v2-mode-row { - display: flex; - align-items: center; - gap: var(--space-2); - flex: 0 0 auto; - min-width: 0; - flex-wrap: nowrap; - justify-self: end; - /* Keep the Sub-agent chip row on the same cap-height as Shadow Call controls. */ - min-height: 28px; -} - .models-v2-detail-row { gap: var(--space-2); margin-bottom: var(--space-2); @@ -234,37 +241,6 @@ align-items: center; } -/* - "Keep ChatGPT on v1" is a sub-setting of the Sub-agent surface control, not a - peer of it. It sits in the right-hand grid column directly under the v1/base/v2 - chips and stays right-aligned with them, so it reads as a qualifier on the - selected mode instead of floating in the empty space beside Shadow Call. -*/ -.models-v2-keep-native-row { - grid-column: 2; - justify-self: end; - display: flex; - justify-content: flex-end; - min-width: 0; - /* Pull up against the mode row's own min-height so the pair reads as one block. */ - margin-top: calc(var(--space-1) * -1); -} - -.models-v2-keep-native { - display: inline-flex; - align-items: center; - gap: var(--space-2); - min-width: 0; -} - -.models-v2-keep-native-label { - color: var(--muted); - white-space: nowrap; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; -} - /* Sits in the same 24px gutter as the Sub-agent help button directly above it, so both rows end on one right edge. */ .models-v2-keep-native-info { @@ -572,37 +548,12 @@ /* ── Responsive ───────────────────────────────────────── */ @media (max-width: 1160px) { - .models-control-top-row { - grid-template-columns: 1fr; - } - - .models-shadow-row { - flex: 1 1 100%; - flex-wrap: wrap; - } - .models-shadow-model-slot { flex: 1 1 12rem; width: auto; min-width: min(100%, 10rem); max-width: 100%; } - - .models-v2-mode-row { - flex-wrap: wrap; - justify-self: start; - } - - .models-v2-keep-native-row { - grid-column: 1; - justify-self: start; - justify-content: flex-start; - margin-top: 0; - } - - .models-v2-keep-native { - white-space: normal; - } } /* @@ -762,6 +713,8 @@ } .models-settings-body > .models-control-top-row { margin-bottom: var(--space-3); } .models-settings-body .models-cap-row { margin-bottom: var(--space-3); } +/* The two cap rows put their controls on the same right edge as the rows above. */ +.models-settings-body .models-cap-row > .muted:first-child { margin-right: auto; } .models-settings-body > .models-aliases-card { margin: var(--space-2) 0 0; } .models-info-hint { @@ -776,22 +729,3 @@ .models-collapse-controls { align-items: center; flex-wrap: wrap; } .models-collapse-controls > .models-custom-summary { margin: 0 0 0 auto; } -/* - Wide workspace: the open panel lays its rows out in two columns instead of one tall - stack. Rows that carry their own internal grid or a list span the full width. -*/ -@container models-workspace (min-width: 1000px) { - .models-settings-body { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - column-gap: var(--space-6); - row-gap: 0; - align-items: start; - } - .models-settings-body > .models-control-top-row, - .models-settings-body > .models-v2-detail-row, - .models-settings-body > .picker-order-editor, - .models-settings-body > .models-aliases-card { - grid-column: 1 / -1; - } -} diff --git a/gui/tests/models-auto-review-override.test.tsx b/gui/tests/models-auto-review-override.test.tsx new file mode 100644 index 00000000000..424c52f676c --- /dev/null +++ b/gui/tests/models-auto-review-override.test.tsx @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; +import { modelsSettingsSummary } from "../src/pages/models-settings-summary"; +import { routedModelOptions, type ModelRow } from "../src/pages/models-shared"; +import type { TFn } from "../src/i18n/shared"; + +/** + * The Models settings panel folds the global auto-review override. Two properties matter: + * the dropdown offers routed rows (the selector must name a provider-namespaced catalog row), + * and the summary keeps an enabled control visible, including its pending model state. + */ +const t = ((key: string) => key) as unknown as TFn; + +const base = { + multiAgentMode: "v1" as const, + shadowEnabled: true, + shadowModel: "xai/grok-4.5", + windowOn: false, + windowValue: 350_000, + pickerMode: "most-used", + newModelsOff: false, + aliasesOn: false, +}; + +function row(partial: Pick & Partial): ModelRow { + return { disabled: false, ...partial }; +} + +test("summary names the auto-review override only while it is on", () => { + expect(modelsSettingsSummary(t, base).some(item => item.id === "auto-review")).toBe(false); + const on = modelsSettingsSummary(t, { + ...base, + autoReviewEnabled: true, + autoReviewModel: "9router/ocg-muse-spark-1.3-contributor", + }); + expect(on.find(item => item.id === "auto-review")).toEqual({ + id: "auto-review", + label: "models.autoReviewOverride", + value: "9router/ocg-muse-spark-1.3-contributor", + }); +}); + +test("an enabled override with no model reads as pending instead of vanishing", () => { + const pending = modelsSettingsSummary(t, { ...base, autoReviewEnabled: true, autoReviewModel: "" }); + expect(pending.find(item => item.id === "auto-review")).toEqual({ + id: "auto-review", + label: "models.autoReviewOverride", + value: "models.autoReviewPending", + }); +}); + +test("the override dropdown offers routed rows and keeps a saved native value visible", () => { + const models = [ + row({ provider: "openai", id: "gpt-5.6-sol", namespaced: "gpt-5.6-sol", native: true }), + row({ provider: "9router", id: "ocg-muse-spark-1.3-contributor", namespaced: "9router/ocg-muse-spark-1.3-contributor" }), + ]; + const visible = [ + { value: "gpt-5.6-sol", label: "GPT-5.6-Sol" }, + { value: "9router/ocg-muse-spark-1.3-contributor", label: "muse-spark" }, + ]; + expect(routedModelOptions(visible, models).map(option => option.value)) + .toEqual(["9router/ocg-muse-spark-1.3-contributor"]); + // A value saved by hand in config.json must stay selectable, or the row would read as empty. + expect(routedModelOptions(visible, models, "gpt-5.6-sol").map(option => option.value).sort()) + .toEqual(["9router/ocg-muse-spark-1.3-contributor", "gpt-5.6-sol"]); +}); diff --git a/gui/tests/models-auto-review-settings.test.tsx b/gui/tests/models-auto-review-settings.test.tsx new file mode 100644 index 00000000000..74c3ee9da11 --- /dev/null +++ b/gui/tests/models-auto-review-settings.test.tsx @@ -0,0 +1,263 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { act, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { Window } from "happy-dom"; +import { en } from "../src/i18n/en"; +import { interpolate, type TFn } from "../src/i18n/shared"; +import { useAutoReviewSettings, type AutoReviewSettingsController } from "../src/pages/use-auto-review-settings"; + +const t: TFn = (key, vars) => interpolate(en[key], vars); +const globals = ["document", "window", "navigator", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record; +let win: Window; +let host: HTMLElement; +let root: Root; +let controller: AutoReviewSettingsController; +let requests: Array<{ url: string; init?: RequestInit }>; +let feedback: Array<[boolean, string]>; +let respond: (url: string, init?: RequestInit) => Promise; + +function response(payload: unknown, status = 200): Response { return Response.json(payload, { status }); } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return { promise, resolve }; +} +function Harness({ apiBase }: { apiBase: string }) { + const current = useAutoReviewSettings(apiBase, t, (ok, message) => feedback.push([ok, message])); + // Publish after commit (as the other hook harnesses do) instead of writing module state during render. + useLayoutEffect(() => { controller = current; }, [current]); + return null; +} +async function paint(apiBase: string) { + await act(async () => { root.render(); }); +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])); + win = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + requests = []; feedback = []; + respond = async () => response({ enabled: false, model: "" }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: string, init?: RequestInit) => { + requests.push({ url: String(input), init }); + return respond(String(input), init); + } }); + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); + root = createRoot(host); +}); + +afterEach(async () => { + await act(async () => { root.unmount(); }); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + await win.happyDOM.close(); +}); + +test("A-to-B-to-A ignores the first base's late GET result", async () => { + const abandoned = deferred(); + const currentA = deferred(); + let aReads = 0; + respond = async url => { + if (url.startsWith("/a/")) return ++aReads === 1 ? abandoned.promise : currentA.promise; + return response({ enabled: false, model: "server-b/model" }); + }; + + await paint("/a"); + let abandonedRead!: Promise; + await act(async () => { abandonedRead = controller.load(); }); + const oldSignal = requests[0]!.init!.signal!; + await paint("/b"); + expect(oldSignal.aborted).toBe(true); + await act(async () => { await controller.load(); }); + expect(controller.data).toEqual({ enabled: false, model: "server-b/model" }); + + await paint("/a"); + let freshRead!: Promise; + await act(async () => { freshRead = controller.load(); }); + expect(controller.data).toBeNull(); + await act(async () => { abandoned.resolve(response({ enabled: true, model: "stale-a/model" })); await abandonedRead; }); + expect(controller.data).toBeNull(); + await act(async () => { currentA.resolve(response({ enabled: false, model: "fresh-a/model" })); await freshRead; }); + expect(controller.data).toEqual({ enabled: false, model: "fresh-a/model" }); +}); + +test("a GET overlapping a save cannot replace its state or re-enable writes", async () => { + const staleRead = deferred(); + const write = deferred(); + let reads = 0; + respond = async (_url, init) => { + if (init?.method === "PUT") return write.promise; + return ++reads === 1 + ? response({ enabled: false, model: "confirmed/model" }) + : staleRead.promise; + }; + await paint("/a"); + await act(async () => { await controller.load(); }); + + let pendingRead!: ReturnType; + await act(async () => { pendingRead = controller.load(); }); + let pendingSave!: Promise; + await act(async () => { pendingSave = controller.save({ enabled: true }); }); + await act(async () => { staleRead.resolve(response({ enabled: false, model: "stale/model" })); await pendingRead; }); + const dataAfterRead = controller.data; + const savingAfterRead = controller.saving; + let secondSave!: Promise; + await act(async () => { secondSave = controller.save({ model: "second/model" }); }); + const writes = requests.filter(request => request.init?.method === "PUT").length; + await act(async () => { + write.resolve(response({ ok: true, enabled: true, model: "confirmed/model", catalogRefresh: { status: "committed" } })); + await Promise.all([pendingSave, secondSave]); + }); + + expect(dataAfterRead).toEqual({ enabled: true, model: "confirmed/model" }); + expect(savingAfterRead).toBe(true); + expect(writes).toBe(1); +}); + +test("an ordinary GET started during a save is ignored", async () => { + const write = deferred(); + let reads = 0; + respond = async (_url, init) => init?.method === "PUT" + ? write.promise + : response({ enabled: false, model: ++reads === 1 ? "confirmed/model" : "stale/model" }); + await paint("/a"); + await act(async () => { await controller.load(); }); + let pendingSave!: Promise; + await act(async () => { pendingSave = controller.save({ enabled: true }); }); + let readResult: unknown; + await act(async () => { readResult = await controller.load(); }); + const requestCount = requests.length; + const dataAfterRead = controller.data; + const savingAfterRead = controller.saving; + await act(async () => { + write.resolve(response({ ok: true, enabled: true, model: "confirmed/model", catalogRefresh: { status: "committed" } })); + await pendingSave; + }); + + expect(readResult).toBeNull(); + expect(requestCount).toBe(2); + expect(dataAfterRead).toEqual({ enabled: true, model: "confirmed/model" }); + expect(savingAfterRead).toBe(true); +}); + +test("a GET begun before a PUT stays stale if it completes after the PUT", async () => { + const lateRead = deferred(); + let reads = 0; + respond = async (_url, init) => { + if (init?.method === "PUT") return response({ ok: true, enabled: true, model: "saved/model", catalogRefresh: { status: "committed" } }); + return ++reads === 1 ? response({ enabled: false, model: "confirmed/model" }) : lateRead.promise; + }; + await paint("/a"); + await act(async () => { await controller.load(); }); + let pendingRead!: ReturnType; + await act(async () => { pendingRead = controller.load(); }); + const readSignal = requests.at(-1)!.init!.signal!; + await act(async () => { await controller.save({ enabled: true, model: "saved/model" }); }); + expect(readSignal.aborted).toBe(true); + await act(async () => { lateRead.resolve(response({ enabled: false, model: "stale/model" })); await pendingRead; }); + + expect(controller.data).toEqual({ enabled: true, model: "saved/model" }); + expect(controller.saving).toBe(false); +}); + +test("explicit reconciliation refreshes settings without ending a save", async () => { + const write = deferred(); + const reconciliation = deferred(); + let reads = 0; + respond = async (_url, init) => { + if (init?.method === "PUT") return write.promise; + return ++reads === 1 + ? response({ enabled: false, model: "confirmed/model" }) + : reconciliation.promise; + }; + await paint("/a"); + await act(async () => { await controller.load(); }); + let pendingSave!: Promise; + await act(async () => { pendingSave = controller.save({ enabled: true }); }); + let refresh!: ReturnType; + await act(async () => { refresh = controller.load({ reconcile: true }); }); + await act(async () => { + reconciliation.resolve(response({ enabled: false, model: "fresh/model" })); + await refresh; + }); + const dataAfterRefresh = controller.data; + const savingAfterRefresh = controller.saving; + await act(async () => { + write.resolve(response({ ok: true, enabled: true, model: "confirmed/model", catalogRefresh: { status: "committed" } })); + await pendingSave; + }); + + expect(dataAfterRefresh).toEqual({ enabled: false, model: "fresh/model" }); + expect(savingAfterRefresh).toBe(true); +}); + +test("failed save reloads the confirmed server value and reports the error", async () => { + let reads = 0; + respond = async (_url, init) => { + if (init?.method === "PUT") return response({ error: "save rejected" }, 500); + reads++; + return response({ enabled: false, model: reads === 1 ? "initial/model" : "current-on-server/model" }); + }; + await paint("/a"); + await act(async () => { await controller.load(); }); + await act(async () => { await controller.save({ enabled: true }); }); + expect(controller.data).toEqual({ enabled: false, model: "current-on-server/model" }); + expect(feedback).toEqual([[false, "save rejected"]]); +}); + +test("failed save keeps the confirmed value when reconciliation also fails", async () => { + let reads = 0; + respond = async (_url, init) => { + if (init?.method === "PUT") return response({ error: "save rejected" }, 500); + reads++; + return reads === 1 + ? response({ enabled: false, model: "confirmed/model" }) + : response({ error: "offline" }, 503); + }; + await paint("/a"); + await act(async () => { await controller.load(); }); + await act(async () => { await controller.save({ enabled: true }); }); + expect(controller.data).toEqual({ enabled: false, model: "confirmed/model" }); + expect(feedback).toEqual([[false, "save rejected"]]); +}); + +test("saved settings report an uncommitted catalog refresh", async () => { + respond = async (_url, init) => init?.method === "PUT" + ? response({ ok: true, enabled: true, model: "router/reviewer", catalogRefresh: { status: "skipped" } }) + : response({ enabled: false, model: "" }); + await paint("/a"); + await act(async () => { await controller.load(); }); + await act(async () => { await controller.save({ enabled: true, model: "router/reviewer" }); }); + expect(controller.data).toEqual({ enabled: true, model: "router/reviewer" }); + expect(feedback).toEqual([[false, en["codexAuth.catalogRefreshPending"]]]); +}); + +test("a stale save response after a proxy change cannot overwrite the new proxy", async () => { + const write = deferred(); + respond = async (url, init) => { + if (url.startsWith("/a/") && init?.method === "PUT") return write.promise; + if (url.startsWith("/b/")) return response({ enabled: false, model: "server-b/model" }); + return response({ enabled: false, model: "server-a/model" }); + }; + await paint("/a"); + await act(async () => { await controller.load(); }); + let pendingSave!: Promise; + await act(async () => { pendingSave = controller.save({ enabled: true, model: "server-a/reviewer" }); }); + const signal = requests.find(request => request.init?.method === "PUT")!.init!.signal!; + + await paint("/b"); + expect(signal.aborted).toBe(true); + await act(async () => { await controller.load(); }); + await act(async () => { + write.resolve(response({ ok: true, enabled: true, model: "server-a/reviewer", catalogRefresh: { status: "committed" } })); + await pendingSave; + }); + expect(controller.data).toEqual({ enabled: false, model: "server-b/model" }); + expect(feedback).toHaveLength(0); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3d2ddb741f4..9294734b47f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json"], - "explicit": { "prefer-model-alias-ids.test.ts": "server", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", + "explicit": { "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "owner-registry-isolation.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 63329af9470..789e482c76d 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -26,7 +26,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin | Chapter | Declared capabilities | |---|---| | [lifecycle](01_surface_lifecycle.md) | 12 | -| [providers-models](01_surface_providers-models.md) | 47 | +| [providers-models](01_surface_providers-models.md) | 49 | | [accounts](01_surface_accounts.md) | 42 | | [agents-routing](01_surface_agents-routing.md) | 51 | | [integrations](01_surface_integrations.md) | 41 | @@ -475,6 +475,14 @@ Original invocation order. These headings preserve links to the previous single- [State-changing task](01_surface_providers-models.md#ocx-models-shadow-set) +### `ocx models auto-review status` + +[Read-oriented task](01_surface_providers-models.md#ocx-models-auto-review-status) + +### `ocx models auto-review set` + +[State-changing task](01_surface_providers-models.md#ocx-models-auto-review-set) + ### `ocx alias list` [Read-oriented task](01_surface_providers-models.md#ocx-alias-list) @@ -1401,6 +1409,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 338 -- of those, state-changing: 207 +- declared capabilities: 340 +- of those, state-changing: 208 - head-resolved invocations: 2 diff --git a/skills/ocx/references/01_surface_providers-models.md b/skills/ocx/references/01_surface_providers-models.md index 05965bca20b..9d5eb7c9d44 100644 --- a/skills/ocx/references/01_surface_providers-models.md +++ b/skills/ocx/references/01_surface_providers-models.md @@ -8,7 +8,7 @@ Use these declarations to choose a task, then check its flags and authority before execution. Non-mutating probes may still contact providers, consume quota or refresh caches. -Declared capabilities: 47. +Declared capabilities: 49. ### `ocx models price` @@ -800,6 +800,45 @@ JSON mode: `payload`. - At least a model or --enabled is required; - clears the model with an empty string. Enabling shadow calls can cause subsequent inference calls. +### `ocx models auto-review status` + +Usage: `ocx models auto-review status [--json]` + +Read the global auto-review model and enabled state. + +State-changing: no. + +| Method | Route | +|---|---| +| GET | `/api/auto-review-settings` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit auto-review settings. | + +JSON mode: `payload`. + +### `ocx models auto-review set` + +Usage: `ocx models auto-review set [model|-] [--enabled ] [--json]` + +Set the global auto-review model or enabled state. + +State-changing: yes. + +| Method | Route | +|---|---| +| PUT | `/api/auto-review-settings` | + +| Flag | Value | Meaning | +|---|---|---| +| `--enabled` | string | on or off; takes a value. | +| `--json` | boolean | Emit the settings receipt. | + +JSON mode: `payload`. + +- At least a model or --enabled is required; - clears the model with an empty string. Human output points to ocx sync when a catalog refresh can be retried and asks operators to resolve a failed refresh before syncing. + ### `ocx alias list` Usage: `ocx alias list [--json]` diff --git a/src/cli/capabilities-provider-models.ts b/src/cli/capabilities-provider-models.ts index 00749e71c03..abf1b61cfc9 100644 --- a/src/cli/capabilities-provider-models.ts +++ b/src/cli/capabilities-provider-models.ts @@ -207,6 +207,17 @@ export const PROVIDER_MODEL_CAPABILITIES: readonly Capability[] = [ flags: [{ name: "--enabled", value: "string", summary: "on or off; takes a value." }, { name: "--json", value: "boolean", summary: "Emit the settings receipt." }], details: ["At least a model or --enabled is required; - clears the model with an empty string. Enabling shadow calls can cause subsequent inference calls."], }, + { + command: ["models", "auto-review", "status"], summary: "Read the global auto-review model and enabled state.", + usage: "ocx models auto-review status [--json]", routes: [{ method: "GET", path: "/api/auto-review-settings" }], mutates: false, json: "payload", + flags: [{ name: "--json", value: "boolean", summary: "Emit auto-review settings." }], + }, + { + command: ["models", "auto-review", "set"], summary: "Set the global auto-review model or enabled state.", + usage: "ocx models auto-review set [model|-] [--enabled ] [--json]", routes: [{ method: "PUT", path: "/api/auto-review-settings" }], mutates: true, json: "payload", + flags: [{ name: "--enabled", value: "string", summary: "on or off; takes a value." }, { name: "--json", value: "boolean", summary: "Emit the settings receipt." }], + details: ["At least a model or --enabled is required; - clears the model with an empty string. Human output points to ocx sync when a catalog refresh can be retried and asks operators to resolve a failed refresh before syncing."], + }, { command: ["alias", "list"], summary: "Read provider and model aliases.", usage: "ocx alias list [--json]", routes: [{ method: "GET", path: "/api/aliases" }], mutates: false, json: "payload", diff --git a/src/cli/models-runtime-subcommands.ts b/src/cli/models-runtime-subcommands.ts index edb7de419aa..0a2b6c2f8e4 100644 --- a/src/cli/models-runtime-subcommands.ts +++ b/src/cli/models-runtime-subcommands.ts @@ -28,6 +28,7 @@ export const MODELS_RUNTIME_SUBCOMMANDS = [ "new-arrivals", "context", "shadow", + "auto-review", "order", "display-name", ] as const; diff --git a/src/cli/models-runtime.ts b/src/cli/models-runtime.ts index 1797d92e414..30fcefd0ac5 100644 --- a/src/cli/models-runtime.ts +++ b/src/cli/models-runtime.ts @@ -48,6 +48,7 @@ const USAGE = `Usage: ocx models new-arrivals [--json] ${MODELS_CONTEXT_USAGE} ocx models shadow [model|-] [--enabled ] [--json] + ocx models auto-review [model|-] [--enabled ] [--json] Prices are USD per 1M tokens. Omitted cache rates default to 0. Price selectors use the exact upstream model ID after the first slash.`; @@ -593,6 +594,39 @@ async function shadow(argv: string[], deps: RuntimeApiDeps): Promise { printData(result, wantsJson, ["Shadow-call settings updated."]); } +async function autoReview(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const action = (args.shift() ?? "status").toLowerCase(); + const wantsJson = takeFlag(args, "--json"); + if (action === "status") { + rejectArgs(args, USAGE); + const result = await runtimeRequest("/api/auto-review-settings", {}, deps); + printData(result, wantsJson); + return; + } + if (action !== "set") throw new CliUsageError("unknown auto-review action " + action, USAGE); + const modelRaw = args[0] && !args[0].startsWith("--") ? args.shift() : undefined; + const enabled = takeBooleanOption(args, "--enabled"); + rejectArgs(args, USAGE); + const body: Record = {}; + if (modelRaw !== undefined) body.model = modelRaw === "-" ? "" : modelRaw; + if (enabled !== undefined) body.enabled = enabled; + if (Object.keys(body).length === 0) throw new CliUsageError("model and/or --enabled is required", USAGE); + const result = await runtimeRequest<{ catalogRefresh?: { status?: string; retryable?: boolean } }>( + "/api/auto-review-settings", { method: "PUT", body: JSON.stringify(body) }, deps, + ); + const refresh = result.catalogRefresh; + printData(result, wantsJson, [ + "Auto-review settings updated.", + ...(refresh?.status === "failed" + ? ["Settings saved, but the Codex model catalog refresh failed. Resolve the refresh issue before running ocx sync."] + : refresh?.status === "skipped" && refresh.retryable === true + ? ["Settings saved, but the Codex model catalog refresh is pending. Run ocx sync to retry."] + : []), + ]); + if (refresh?.status === "failed") return 1; +} + export async function handleModelsRuntimeCommand(sub: string, argv: string[], deps: RuntimeApiDeps = {}): Promise { if (sub === "order" || sub === "display-name") { const { handleModelsOrderCommand, handleModelsDisplayNameCommand } = await import("./models-order"); @@ -601,7 +635,7 @@ export async function handleModelsRuntimeCommand(sub: string, argv: string[], de // The dispatch below and MODELS_RUNTIME_SUBCOMMANDS must name the same set; // tests/cli/cli-models-runtime-dispatch.test.ts fails if they drift (#3094). if (!isModelsRuntimeSubcommand(sub)) return null; - let action: (() => Promise) | undefined; + let action: (() => Promise) | undefined; if (sub === "live") action = () => live(argv, deps); else if (sub === "price") action = () => price(false, argv, deps); else if (sub === "set-price") action = () => price(true, argv, deps); @@ -616,8 +650,11 @@ export async function handleModelsRuntimeCommand(sub: string, argv: string[], de else if (sub === "new-arrivals") action = () => newArrivals(argv, deps); else if (sub === "context") action = () => context(argv, deps); else if (sub === "shadow") action = () => shadow(argv, deps); + else if (sub === "auto-review") action = () => autoReview(argv, deps); if (!action) return null; - return runCliAction(action); + let outcome: number | void = 0; + const exit = await runCliAction(async () => { outcome = await action(); }); + return exit || outcome || 0; } export const MODELS_RUNTIME_USAGE = USAGE; diff --git a/src/codex/catalog/auto-review.ts b/src/codex/catalog/auto-review.ts index be416776b87..dfb4b603ba2 100644 --- a/src/codex/catalog/auto-review.ts +++ b/src/codex/catalog/auto-review.ts @@ -480,20 +480,37 @@ function configHasProviderAutoReview(config: Pick): bool provider.autoReviewModel !== undefined || provider.autoReviewModelOverrides !== undefined); } +/** + * The global override selector, when the operator enabled it and named a model. + * + * Occupies the same slot as Codex's root `auto_review_model` fallback and wins over it: an + * enabled Model-settings choice is the operator's explicit pick, while a disabled or blank block + * leaves the root selector in charge. Provider-scoped stamps keep their precedence separately. + */ +export function resolveAutoReviewOverrideSelector( + config?: Pick, +): string | undefined { + const block = config?.autoReviewOverride; + if (block?.enabled !== true) return undefined; + const model = typeof block.model === "string" ? block.model.trim() : ""; + return model.length > 0 ? model : undefined; +} + /** Apply the root Codex auto-review selector after the final catalog merge. */ export function finalizeAutoReviewModelOverride( models: RawEntry[] | undefined, sourceModels: readonly RawEntry[] = [], - config?: Pick, + config?: Pick, ): AutoReviewModelOverrideResult { if (models && sourceModels.length > 0) preserveNativeAutoReviewModelOverrides( models.filter(entry => !isCodexControlPlaneModel(entry.slug)), sourceModels.filter(entry => !isCodexControlPlaneModel(entry.slug)), ); + const rootValue = resolveAutoReviewOverrideSelector(config) ?? readConfiguredAutoReviewModel(); if (config && configHasProviderAutoReview(config)) { - return applyConfiguredAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), config, sourceModels); + return applyConfiguredAutoReviewModelOverride(models, rootValue, config, sourceModels); } - return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels); + return applyAutoReviewModelOverride(models, rootValue, sourceModels); } /** * Why an account-gated native model stopped being offered, but only when the answer is one the diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index dd3a04cd0d9..c748e02d608 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -10,6 +10,7 @@ import { anthropicSidecarConfigError } from "./schema/anthropic-account-pool"; import { modelPinnedEffortsConfigError, pinnedReasoningEffortConfigError, + autoReviewOverrideConfigError, autoReviewModelOverridesConfigError, autoReviewModelTargetConfigError, modelCapabilitiesConfigError, @@ -413,6 +414,11 @@ export function sanitizeModelCostsForLoad(parsed: unknown): void { export function sanitizeAutoReviewForLoad(parsed: unknown): void { if (!parsed || typeof parsed !== "object") return; const root = parsed as Record; + if (root.autoReviewOverride !== undefined + && autoReviewOverrideConfigError(root.autoReviewOverride) !== null) { + console.warn("⚠️ config.json autoReviewOverride is invalid — ignoring the override"); + delete root.autoReviewOverride; + } const providers = root.providers; if (!providers || typeof providers !== "object" || Array.isArray(providers)) return; for (const [name, providerValue] of Object.entries(providers as Record)) { diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index 71ef575a2fc..70842b39add 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -257,10 +257,11 @@ export function autoReviewModelTargetConfigError( field = "autoReviewModel", allowClear = false, ): string | null { - if (value === undefined || (allowClear && (value === null || value === ""))) return null; + if (value === undefined || (allowClear && value === null)) return null; if (typeof value !== "string") return `${field} must be a string`; const trimmed = value.trim(); - if (!trimmed) return `${field} must be nonblank`; + // `allowClear` callers trim then delete, so whitespace-only is a clear, not a blank selector. + if (!trimmed) return allowClear ? null : `${field} must be nonblank`; if (trimmed.length > 1024 || AUTO_REVIEW_MODEL_CONTROL_CHARS.test(trimmed)) { return `${field} must be a catalog selector without whitespace or control characters`; } @@ -272,6 +273,26 @@ export function isValidAutoReviewModel(value: unknown): value is string { return typeof value === "string" && autoReviewModelTargetConfigError(value) === null; } +/** + * Validate the global auto-review override block the Models settings panel writes. + * + * `enabled` is optional so a save may carry only the model during an edit; `model` follows the + * same selector rules as the provider-scoped field, and an empty value clears the override. + */ +export function autoReviewOverrideConfigError(value: unknown, field = "autoReviewOverride"): string | null { + if (value === undefined || value === null) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return `${field} must be a plain object with own properties`; + } + const block = value as Record; + if (block.enabled !== undefined && typeof block.enabled !== "boolean") { + return `${field}.enabled must be a boolean`; + } + return autoReviewModelTargetConfigError(block.model, `${field}.model`, true); +} + /** Canonical model key used for map matching, duplicate detection, and route tombstones. */ export function canonicalAutoReviewModelKey(modelId: string): string { return encodeRoutedModelId(modelId.trim()); diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index abe9d3f3a57..8b8b577c2c6 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -31,6 +31,7 @@ import { CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR, codexAccountNamespacesSchema, modelPinnedEffortsSchema, + autoReviewOverrideSchema, compactionRoutingSchema, memoryModelSettingSchema, memoryModelsSchema, @@ -193,6 +194,9 @@ export const configSchema = z.object({ z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535).optional() }), ]).optional().catch(undefined), providers: z.record(z.string(), providerConfigSchema), + // Models settings panel writes the global reviewer override; an invalid hand edit is + // stripped by load degradation so it cannot reject the providers that carry it. + autoReviewOverride: autoReviewOverrideSchema.optional(), modelPinnedEfforts: modelPinnedEffortsSchema.optional(), compactionRouting: compactionRoutingSchema.optional().catch(undefined), compactionRecovery: compactionRecoverySchema.optional().catch(undefined), diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index dcbabdf148b..158d9dc9ec7 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -6,6 +6,7 @@ import { modelPinnedEffortsConfigError, pinnedReasoningEffortConfigError, modelDisplayNamesConfigError, + autoReviewOverrideConfigError, autoReviewModelOverridesConfigError, autoReviewModelTargetConfigError, normalizeNonBlankStringArray, @@ -28,6 +29,7 @@ import { PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS, UPSTREAM_HTTP_VERSION_VALUES, type OcxProviderConfig, + type OcxConfig, type FastWire, type ProviderCostOverlay, } from "../../types"; @@ -257,6 +259,12 @@ const autoReviewModelOverridesSchema = z.unknown().superRefine((value, ctx) => { if (error) ctx.addIssue({ code: "custom", message: error }); }).transform(value => normalizeAutoReviewModelOverrides(value)); +/** Global auto-review override block; a malformed hand edit is stripped by load degradation. */ +export const autoReviewOverrideSchema = z.unknown().superRefine((value, ctx) => { + const error = autoReviewOverrideConfigError(value, "autoReviewOverride"); + if (error) ctx.addIssue({ code: "custom", message: error }); +}).transform(value => value as NonNullable); + const modelCapabilitiesSchema = z.unknown().superRefine((value, ctx) => { const error = modelCapabilitiesConfigError(value); if (error) ctx.addIssue({ code: "custom", message: error }); diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index e4bfffe5caf..a906733c99d 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -122,6 +122,8 @@ import { } from "../../lib/debug-settings"; import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types"; import { shadowCallTargetError } from "./shadow-call-validation"; +import { autoReviewOverrideConfigError } from "../../config/provider-validation"; +import { ConfigWritePublishedError } from "../../config/persist-unlocked"; import { drainAndShutdown } from "../lifecycle"; import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; @@ -1298,5 +1300,38 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise