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 16227fe5cf1..7404119ad41 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1749,7 +1749,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 65a9ef46769..8c70dd38540 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 210221f4d50..9054fedfcab 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1126,7 +1126,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",