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 70fc8ccf02d..2ad4668e25c 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -314,6 +314,22 @@ advertised where it cannot be honored. An `openai-chat` destination can authoriz eligible model with `chatServiceTier: true`, or authorize only exact models with `modelSupportsServiceTier`; Responses routes do not need that extra Chat wire authorization. +### Selecting Flex on eligible routed models + +On NanoGPT, `openai/gpt-6-luna` now advertises **Flex** and **Fast** in Codex's model speed selector. +Other gateways can opt in per verified model by adding `modelSupportsServiceTier` +and `fastWire: { kind: "service-tier", canonicalToWire: { priority: "priority", flex: "flex" }, foreignCallerTiers: "verbatim" }` +to their provider entry. For `openai-chat` adapters, also set `chatServiceTier: true` +so the caller's Flex request reaches the upstream API. This declares support; it does not +change pricing or grant an account entitlement. + +Flex is **not** enabled by Fast mode: it must be selected explicitly in Codex. +Leave opencodex's global `fastMode` on Auto; forced Fast or forced Default overrides +caller-selected Flex. Only an eligible exact routed model receives the Flex catalog tier, +and removing the capability withdraws the generated choice after synchronization. +Native/account rows and combos are unchanged. Flex may have higher latency or +temporary resource unavailability. A tier choice is not a promise of provider acceptance. + ### Selecting Ultra Fast in Codex Desktop For a provider that supports `service_tier: "ultrafast"`, declare that wire mapping once. 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 899ffc19752..3ad28e29fc4 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 @@ -172,6 +172,20 @@ fast_mode = true `service_tier`가 제거됩니다(레지스트리가 정식 OpenAI를 `true`, DeepSeek과 Volcengine Ark를 `false`로 분류). 미분류 커스텀 게이트웨이는 호출자가 준 값을 그대로 보존하고 주입도 받지 않습니다. 따라서 처리 불가능한 곳에 fast 옵션이 노출되지 않으며, 커스텀 게이트웨이는 `true`로 명시적으로 옵트인할 수 있습니다. +### 지원되는 라우팅 모델에서 Flex 선택 + +NanoGPT의 `openai/gpt-6-luna`는 Codex 모델 속도 선택 메뉴에 **Flex**와 **Fast**를 표시합니다. +다른 게이트웨이는 검증된 모델에 대해 `modelSupportsServiceTier`를 지정하고, +프로바이더 설정에 `fastWire: { kind: "service-tier", canonicalToWire: { priority: "priority", flex: "flex" }, foreignCallerTiers: "verbatim" }`를 선언할 수 있습니다. +`openai-chat` 어댑터라면 `chatServiceTier: true`도 필요합니다. +이 설정은 지원 여부를 선언할 뿐 요금이나 실제 계정 권한을 보장하지 않습니다. + +Flex는 Fast를 켜는 것만으로 활성화되지 않으며 Codex에서 직접 선택해야 합니다. +OpenCodex의 전역 `fastMode`는 Auto로 유지하세요. Fast/Default 강제 모드는 Flex 선택보다 +우선합니다. 명시적으로 지원하는 라우팅 모델에만 Flex를 추가하고, 지원 선언이 없어지면 +동기화 이후 자동 생성된 항목을 제거합니다. 네이티브·계정별 모델과 콤보는 변경하지 않습니다. +Flex 요청은 지연이 길거나 리소스 부족으로 실패할 수 있습니다. + ### Codex 데스크톱에서 Ultra Fast 선택 `service_tier: "ultrafast"`를 지원하는 프로바이더에는 전송 매핑을 한 번 설정하면 됩니다. diff --git a/src/codex/catalog/build-entries.ts b/src/codex/catalog/build-entries.ts index 205e78d77ba..e6376b7cfaa 100644 --- a/src/codex/catalog/build-entries.ts +++ b/src/codex/catalog/build-entries.ts @@ -49,7 +49,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, configuredUltraFastSlugs } from "./configured-tiers"; +import { applyConfiguredFlex, applyConfiguredUltraFast, configuredFlexSlugs, configuredUltraFastSlugs } from "./configured-tiers"; import { applyNativeUltraFastTier } from "./native-ultrafast"; export interface ObservedCatalogEntryBuildInput { @@ -114,11 +114,13 @@ export function buildCatalogEntries( }); // Live discovery does not run the disk merge; use the same captured declarations and row fences. const configured = configuredUltraFastSlugs(goModels); + const configuredFlex = configuredFlexSlugs(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); + applyConfiguredFlex(entry, configuredFlex.has(entry.slug)); } applyFullModelPickerOrder(entries, modelPickerOrder); return entries; @@ -485,6 +487,8 @@ export interface ObservedCatalogMergeInput { readonly ultraFastTier?: boolean; /** Current captured wire declarations; disk rows and templates never populate this set. */ readonly configuredUltraFastSlugs?: ReadonlySet; + /** Exact provider/model declarations for the optional Flex picker tier. */ + readonly configuredFlexSlugs?: ReadonlySet; readonly catalogModels: readonly RawEntry[]; readonly baselineCatalogModels: readonly RawEntry[]; readonly routedEntries: readonly RawEntry[]; @@ -532,6 +536,7 @@ export interface ObservedCatalogMergeInput { export function mergeCatalogEntriesFromObservedState({ ultraFastTier = false, configuredUltraFastSlugs = new Set(), + configuredFlexSlugs = new Set(), catalogModels, baselineCatalogModels, routedEntries, @@ -888,6 +893,7 @@ export function mergeCatalogEntriesFromObservedState({ || isNativeAliasCatalogEntry(entry) || isExactComboCatalogEntry(entry, exactComboSlugs) || trustedAccountBoundNativeCatalogSlug(entry) !== undefined) continue; applyConfiguredUltraFast(entry, operatorRows.get(entry.slug), configuredUltraFastSlugs.has(entry.slug), ultraFastTier); + applyConfiguredFlex(entry, configuredFlexSlugs.has(entry.slug)); } const finalRoutedEntrySet = new Set(finalRoutedEntries); const degradedPreservedCount = preservedRoutedEntries.filter(entry => { diff --git a/src/codex/catalog/configured-tiers.ts b/src/codex/catalog/configured-tiers.ts index 7eb723550b2..e557077a098 100644 --- a/src/codex/catalog/configured-tiers.ts +++ b/src/codex/catalog/configured-tiers.ts @@ -5,6 +5,55 @@ import { preserveOperatorUltraFastTiers } from "./operator-tiers"; const MANAGED_ULTRAFAST = "opencodex_managed_ultrafast"; +/** Flex is a foreign caller tier: advertise it only when the final wire can forward it verbatim. */ +export function supportsConfiguredFlex(policy: ResolvedFastPolicy | undefined): boolean { + return policy?.eligibility === "eligible" + && policy.forwardCallerTier === true + && policy.fastWire?.kind === "service-tier" + && policy.fastWire.foreignCallerTiers === "verbatim" + && policy.fastWire.canonicalToWire.flex === "flex"; +} + +/** An exact routed capability, never inferred from a model family or template. */ +export function configuredFlexSlugs(models: readonly CatalogModel[]): ReadonlySet { + const declarations = new Map(); + for (const model of models) { + const slug = catalogModelSlug(model); + const supported = model.provider !== COMBO_NAMESPACE && model.nativeAlias !== true + && model.supportsServiceTier === true && model.supportsFlexTier === true; + declarations.set(slug, supported && declarations.get(slug) !== false); + } + return new Set([...declarations].filter(([, supported]) => supported).map(([slug]) => slug)); +} + +/** + * Publish the optional Flex selector. A generated tier is marked so a stale + * persisted row cannot keep it after the live capability has been withdrawn. + */ +export function applyConfiguredFlex(entry: RawEntry, configured: boolean): void { + const marker = "opencodex_managed_flex"; + if (entry[marker] === entry.slug) { + if (Array.isArray(entry.service_tiers)) { + entry.service_tiers = entry.service_tiers.filter(tier => tier?.id !== "flex"); + } + if (Array.isArray(entry.additional_speed_tiers)) { + entry.additional_speed_tiers = entry.additional_speed_tiers.filter(tier => tier !== "flex"); + } + } + delete entry[marker]; + if (!configured) return; + const tiers = Array.isArray(entry.service_tiers) ? entry.service_tiers : []; + if (!tiers.some(tier => tier?.id === "flex")) { + entry.service_tiers = [...tiers, { + id: "flex", name: "Flex", + description: "Lower cost, slower processing; provider access required", + }]; + } + const speeds = Array.isArray(entry.additional_speed_tiers) ? entry.additional_speed_tiers : []; + if (!speeds.includes("flex")) entry.additional_speed_tiers = [...speeds, "flex"]; + entry[marker] = entry.slug; +} + /** Advertise Ultra Fast only when the eligible final wire explicitly maps that exact tier. */ export function supportsConfiguredUltraFast(policy: ResolvedFastPolicy | undefined): boolean { return policy?.eligibility === "eligible" diff --git a/src/codex/catalog/model-hints.ts b/src/codex/catalog/model-hints.ts index 6d9a13d060f..c2bad364722 100644 --- a/src/codex/catalog/model-hints.ts +++ b/src/codex/catalog/model-hints.ts @@ -1,7 +1,7 @@ import { canInferVendorContext, vendorContextInference } from "./vendor-context"; import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases"; import { initialModelSelectionPending } from "../../providers/initial-model-selection"; -import { supportsConfiguredUltraFast } from "./configured-tiers"; +import { supportsConfiguredFlex, supportsConfiguredUltraFast } from "./configured-tiers"; import { execFileSync } from "node:child_process"; import { createHash, createHmac, randomBytes } from "node:crypto"; import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; @@ -319,6 +319,7 @@ export function applyProviderConfigHints( contextCapped: previousContextCapped, supportsServiceTier: _staleServiceTier, supportsUltraFastTier: _staleUltraFastTier, + supportsFlexTier: _staleFlexTier, fastTierDescription: _staleFastTierDescription, providerAlias: _staleProviderAlias, suppressSyntheticMax: _staleSuppressSyntheticMax, @@ -360,6 +361,7 @@ export function applyProviderConfigHints( ...(typeof supportsVerbosity === "boolean" ? { supportsVerbosity } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), supportsUltraFastTier: supportsConfiguredUltraFast(fastPolicy), + supportsFlexTier: supportsConfiguredFlex(fastPolicy), ...(supportsServiceTier === true && fastPolicy.fastTierDescription !== undefined ? { fastTierDescription: fastPolicy.fastTierDescription } : {}), diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index bdab81c61e3..7af06535928 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -133,6 +133,8 @@ export interface CatalogModel { supportsServiceTier?: boolean; /** Captured eligible service-tier wire explicitly maps ultrafast to ultrafast. */ supportsUltraFastTier?: boolean; + /** Captured eligible service-tier wire explicitly supports caller-selected Flex. */ + supportsFlexTier?: boolean; /** Optional provider-specific copy for the advertised Fast tier. */ fastTierDescription?: string; supportsReasoningSummaries?: boolean; diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index 4994f28dca7..3f4e8aeabc6 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -1,5 +1,5 @@ import { projectAntigravitySelectedModels } from "../../providers/antigravity-effort-families"; -import { configuredUltraFastSlugs } from "./configured-tiers"; +import { configuredFlexSlugs, configuredUltraFastSlugs } from "./configured-tiers"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { initializeConfigOwnership } from "../../lib/config-ownership"; @@ -511,6 +511,7 @@ function writeRetainedCatalogSync({ catalog.models = mergeCatalogEntriesFromObservedState({ ultraFastTier: ultraFastTierEnabled(config), configuredUltraFastSlugs: configuredUltraFastSlugs(orderedGoModels), + configuredFlexSlugs: configuredFlexSlugs(orderedGoModels), modelPickerOrder, accountSelectors, catalogModels: catalogModelsForMerge, diff --git a/src/codex/catalog/routed-gather.ts b/src/codex/catalog/routed-gather.ts index 13bf24a7064..23fafa4778c 100644 --- a/src/codex/catalog/routed-gather.ts +++ b/src/codex/catalog/routed-gather.ts @@ -1,7 +1,7 @@ import { canInferVendorContext } from "./vendor-context"; import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases"; import { initialModelSelectionPending } from "../../providers/initial-model-selection"; -import { supportsConfiguredUltraFast } from "./configured-tiers"; +import { supportsConfiguredFlex, supportsConfiguredUltraFast } from "./configured-tiers"; import { execFileSync } from "node:child_process"; import { createHash, createHmac, randomBytes } from "node:crypto"; import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; @@ -364,6 +364,7 @@ async function gatherRoutedModelsUncached( const supported = serviceTierSupportFromPolicy(policy); if (supported !== undefined) model.supportsServiceTier = supported; model.supportsUltraFastTier = supportsConfiguredUltraFast(policy); + model.supportsFlexTier = supportsConfiguredFlex(policy); if (supported === true && policy.fastTierDescription !== undefined) model.fastTierDescription = policy.fastTierDescription; } } @@ -661,6 +662,7 @@ async function gatherRoutedModelsUncached( ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), supportsUltraFastTier: supportsConfiguredUltraFast(fastPolicy), + supportsFlexTier: supportsConfiguredFlex(fastPolicy), ...(supportsServiceTier === true && fastPolicy?.fastTierDescription !== undefined ? { fastTierDescription: fastPolicy.fastTierDescription } : {}), diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index fc4de4a6b20..c6d09447492 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -1,5 +1,5 @@ import { projectAntigravitySelectedModels } from "../providers/antigravity-effort-families"; -import { configuredUltraFastSlugs } from "./catalog/configured-tiers"; +import { configuredFlexSlugs, configuredUltraFastSlugs } from "./catalog/configured-tiers"; import { join } from "node:path"; import { sameCatalogHealPath } from "./catalog/heal-observation"; @@ -373,6 +373,7 @@ function prepareCatalog( const mergedModels = mergeCatalogEntriesFromObservedState({ ultraFastTier: ultraFastTierEnabled(config), configuredUltraFastSlugs: configuredUltraFastSlugs(ordered), + configuredFlexSlugs: configuredFlexSlugs(ordered), modelPickerOrder, accountSelectors, catalogModels, diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 1fd4039425d..7ec2bb810f9 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -701,7 +701,19 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ preserveResponsesReasoningContent: true, note: "Domestic BigModel Coding Plan Responses endpoint; static model roster", }, - { id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://nano-gpt.com/api" }, + { + id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", + adapter: "openai-chat", authKind: "key", dashboardUrl: "https://nano-gpt.com/api", + // NanoGPT's model catalog advertises both flex and priority for this exact Luna id. + // Do not extend this to other models without equivalent upstream capability evidence. + modelSupportsServiceTier: { "openai/gpt-6-luna": true }, + chatServiceTier: true, + fastWire: { + kind: "service-tier", + canonicalToWire: { priority: "priority", flex: "flex" }, + foreignCallerTiers: "verbatim", + }, + }, { id: "synthetic", label: "Synthetic", baseUrl: "https://api.synthetic.new/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://synthetic.new" }, // SiliconFlow publishes an OpenAI-compatible chat endpoint and a dynamic model catalog. Do not // freeze reasoning controls here: enable_thinking/thinking_budget support and limits vary by diff --git a/structure/catalog.md b/structure/catalog.md index 60a4225768a..7242cc9b695 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -113,6 +113,7 @@ is still not inferred from a GPT name. Stored configuration and native capabilit unchanged. Request-time native effort clamps remain canonical-forward only. 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`. +Flex publication uses the same observed-state entry points but is independently gated: the final wire must be eligible, OpenAI service-tier compatible, and able to forward foreign caller tiers verbatim, with an explicit `flex: "flex"` mapping. Only exact routed models receive an opt-in `Flex` descriptor; no global or per-model default tier is changed. Duplicate declarations fail closed and withdrawn generated tiers lose their ownership marker. NanoGPT declares this capability only for `openai/gpt-6-luna`. Coverage extends the configured-tier integration tests. 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/configured-ultrafast-picker.test.ts b/tests/codex-integration/configured-ultrafast-picker.test.ts index 2afd05a3763..31a7dd57a5c 100644 --- a/tests/codex-integration/configured-ultrafast-picker.test.ts +++ b/tests/codex-integration/configured-ultrafast-picker.test.ts @@ -7,7 +7,7 @@ import type { OcxProviderConfig } from "../../src/types"; import { captureFastPolicyAuthority, fastPolicyForModel } from "../../src/providers/service-tier"; import { decideTier } from "../../src/providers/fastwire"; import { applyProviderConfigHints } from "../../src/codex/catalog/model-hints"; -import { configuredUltraFastSlugs } from "../../src/codex/catalog/configured-tiers"; +import { configuredFlexSlugs, configuredUltraFastSlugs } from "../../src/codex/catalog/configured-tiers"; import { buildCatalogEntriesFromObservedState, mergeCatalogEntriesFromObservedState, type ObservedCatalogMergeInput } from "../../src/codex/catalog/build-entries"; import type { CatalogModel, RawEntry } from "../../src/codex/catalog/parsing"; import { repoRoot } from "../helpers/repo-root"; @@ -124,6 +124,80 @@ describe("configured Ultra Fast in the native Codex speed picker", () => { }); }); + +describe("configured Flex in the native Codex service-tier picker", () => { + function flexProvider(overrides: Partial = {}): OcxProviderConfig { + return provider({ + fastWire: { + kind: "service-tier", canonicalToWire: { priority: "priority", flex: "flex" }, + foreignCallerTiers: "verbatim", + }, + ...overrides, + }); + } + + test("publishes Flex only on a verified routed model, without changing its default", () => { + const config = flexProvider(); + const model = hinted(config); + expect(model.supportsFlexTier).toBe(true); + expect([...configuredFlexSlugs([model])]).toEqual([slug]); + const row = merge([], { + routedEntries: build([model]), + configuredUltraFastSlugs: new Set(), + configuredFlexSlugs: configuredFlexSlugs([model]), + })[0]!; + expect(row.service_tiers.map((tier: { id: string }) => tier.id)).toEqual(["priority", "flex"]); + expect(row.additional_speed_tiers).toEqual(["fast", "flex"]); + expect(row.default_service_tier).toBeNull(); + expect(row.service_tier).toBeUndefined(); + expect(row.opencodex_managed_flex).toBe(slug); + expect(decideTier(fastPolicyForModel(config, "task-model", "gateway"), undefined, "flex")) + .toEqual({ kind: "forward-caller" }); + const removed = merge([row], { + routedEntries: build([hinted()]), + configuredUltraFastSlugs: new Set(), + configuredFlexSlugs: new Set(), + })[0]!; + expect(removed.service_tiers.some((tier: { id: string }) => tier.id === "flex")).toBe(false); + expect(removed.opencodex_managed_flex).toBeUndefined(); + }); + + test.each([ + ["missing flex mapping", { fastWire: undefined }], + ["unclassified model", { modelSupportsServiceTier: {} }], + ["provider opt-out", { supportsServiceTier: false }], + ["model opt-out", { modelSupportsServiceTier: { "task-model": false } }], + ["wrong wire value", { fastWire: { kind: "service-tier", canonicalToWire: { priority: "priority", flex: "priority" }, foreignCallerTiers: "verbatim" } }], + ["foreign tier blocked", { fastWire: { kind: "service-tier", canonicalToWire: { priority: "priority", flex: "flex" }, foreignCallerTiers: "drop" } }], + ["chat forwarding blocked", { adapter: "openai-chat" }], + ] as [string, Partial][])("%s does not publish Flex", (_name, overrides) => { + const model = hinted(flexProvider(overrides)); + expect(model.supportsFlexTier).toBe(false); + expect(configuredFlexSlugs([model]).size).toBe(0); + }); + + test("conflicting duplicates and combo aliases cannot borrow Flex", () => { + const model = hinted(flexProvider()); + expect(configuredFlexSlugs([model, { ...model, supportsFlexTier: false }]).size).toBe(0); + expect(configuredFlexSlugs([{ ...model, provider: "combo", nativeAlias: true }]).size).toBe(0); + expect([...configuredFlexSlugs([{ ...model, alias: "gateway/friendly" }])]).toEqual(["gateway/friendly"]); + }); + + test("NanoGPT registry scopes Flex to the exact Luna model", () => { + const config: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: "https://nano-gpt.com/api/v1", authMode: "key", + models: ["openai/gpt-6-luna", "another-model"], liveModels: false, + }; + expect(hinted(config, { provider: "nanogpt", id: "openai/gpt-6-luna" }).supportsFlexTier).toBe(false); + const luna = applyProviderConfigHints("nanogpt", config, { provider: "nanogpt", id: "openai/gpt-6-luna" }); + const other = applyProviderConfigHints("nanogpt", config, { provider: "nanogpt", id: "another-model" }); + expect(luna.supportsFlexTier).toBe(true); + expect(luna.supportsServiceTier).toBe(true); + expect(other.supportsFlexTier).toBe(false); + expect([...configuredFlexSlugs([luna, other])]).toEqual(["nanogpt/openai-gpt-6-luna"]); + }); +}); + for (const writer of ["retained-sync", "convergence"] as const) { test(writer + " updates both app catalog and cache across declaration changes", () => { const root = mkdtempSync(join(tmpdir(), "ocx-configured-ultrafast-")); diff --git a/tests/codex-integration/ultrafast-live-catalog.test.ts b/tests/codex-integration/ultrafast-live-catalog.test.ts index 8df2ecc7897..ec8e39a1468 100644 --- a/tests/codex-integration/ultrafast-live-catalog.test.ts +++ b/tests/codex-integration/ultrafast-live-catalog.test.ts @@ -79,6 +79,21 @@ describe("live Codex catalog Ultra Fast", () => { }); }); + +test("live catalog publishes Flex only with explicit eligible forwarding capability", () => { + const withFlex = model({ + fastWire: { kind: "service-tier", canonicalToWire: { priority: "priority", flex: "flex" }, foreignCallerTiers: "verbatim" }, + }); + const row = build([withFlex]).find(entry => entry.slug === slug)!; + expect(row.service_tiers.map((tier: { id: string }) => tier.id)).toEqual(["priority", "flex"]); + expect(row.additional_speed_tiers).toEqual(["fast", "flex"]); + expect(row.default_service_tier).toBeNull(); + const withdrawn = model(); + expect(build([withdrawn]).find(entry => entry.slug === slug)!.additional_speed_tiers).not.toContain("flex"); + expect(build([withFlex, { ...withFlex, supportsFlexTier: false }]) + .find(entry => entry.slug === slug)!.additional_speed_tiers).not.toContain("flex"); +}); + 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");