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 a83c1e3e37b..2a04b4dcc19 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -320,6 +320,45 @@ 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 in Codex + +For a routed provider and model verified to accept `service_tier: "flex"`, explicitly +declare Flex support alongside the existing Fast wire. Merge these fields into the +**existing** `providers.nanogpt` entry (do not replace its URL or credentials): + +```json +{ + "modelSupportsServiceTier": { "openai/gpt-6-luna": true }, + "fastWire": { + "kind": "service-tier", + "canonicalToWire": { "priority": "priority", "flex": "flex" }, + "foreignCallerTiers": "verbatim" + } +} +``` + +With an eligible `openai-responses` route, the Luna row advertises Standard, +Fast and Flex in Codex's speed selector. For an `openai-chat` route, also set +`chatServiceTier: true` **only if** the upstream Chat Completions endpoint supports +caller-provided service tiers. The `fastWire` mapping is **provider-wide**: +other service-tier-eligible routed models in the same `providers.nanogpt` +entry can also advertise Flex when the catalog publishes them as ordinary routed +rows, even when they are not listed in `modelSupportsServiceTier`. That map is not +an allowlist excluding other eligible models. Combo-namespace and native-alias +rows are **not** published with Flex. Verify Flex support for **every** eligible +ordinary routed model; +if only Luna supports Flex, configure it in a separate provider entry with +no other Fast-eligible models. Do not infer Flex support from Fast alone. + +Keep OpenCodex's global Fast mode on **Auto** (`fastMode` unset), as forcing +Fast or Default overrides the caller's Flex choice. After `ocx sync`, refresh the +Codex model list. Select **Flex** from the speed selector, or use +`/model --speed=flex` in Codex CLI. This is a **selectable tier**, +not a per-model forced default: switching models does not force Flex automatically. +The tier is forwarded as `service_tier: "flex"` and can still be declined by +the upstream; availability, cost and latency depend on the provider. Neither +the menu nor a request alone proves which tier was billed. + ### 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..60b38ca4c9c 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,46 @@ fast_mode = true `service_tier`가 제거됩니다(레지스트리가 정식 OpenAI를 `true`, DeepSeek과 Volcengine Ark를 `false`로 분류). 미분류 커스텀 게이트웨이는 호출자가 준 값을 그대로 보존하고 주입도 받지 않습니다. 따라서 처리 불가능한 곳에 fast 옵션이 노출되지 않으며, 커스텀 게이트웨이는 `true`로 명시적으로 옵트인할 수 있습니다. +### Codex에서 Flex 선택 + +`service_tier: "flex"` 지원이 확인된 라우팅 프로바이더와 모델에 한해 Flex를 +명시적으로 선언할 수 있습니다. 아래 필드는 **기존** `providers.nanogpt` 설정에 +병합해야 하며 URL과 API 키를 덮어쓰면 안 됩니다. + +```json +{ + "modelSupportsServiceTier": { "openai/gpt-6-luna": true }, + "fastWire": { + "kind": "service-tier", + "canonicalToWire": { "priority": "priority", "flex": "flex" }, + "foreignCallerTiers": "verbatim" + } +} +``` + +적격한 `openai-responses` 라우트에서는 Luna의 Codex 속도 선택기에 Standard, +Fast, Flex가 나타납니다. `openai-chat` 라우트는 Chat Completions API가 +호출자의 서비스 등급을 지원하는 것이 확인된 경우에만 `chatServiceTier: true`도 +설정해야 합니다. `fastWire` 매핑은 **프로바이더 전체에 적용**되므로 같은 +`providers.nanogpt` 항목에서 서비스 등급 사용이 이미 허용된 다른 모델에도 +Flex 선택지가 표시될 수 있습니다. `modelSupportsServiceTier`에 Luna만 적어도 +이미 적격인 다른 모델을 제외하는 허용 목록이 되는 것은 아닙니다. 다만 Flex 선택지는 +카탈로그에 게시되는 **일반 라우팅 모델 행**에만 표시되며, Combo 네임스페이스와 +Native Alias 행에는 표시되지 않습니다. 따라서 해당 항목에서 서비스 등급 사용이 +허용된 **모든 일반 라우팅 모델**의 Flex 지원 여부를 확인하세요. +Luna만 Flex를 지원한다면 Fast 지원 모델이 다른 모델과 섞이지 않도록 +**별도 프로바이더 항목**으로 분리해야 합니다. Fast 지원만으로 Flex 지원을 +추정해서는 안 됩니다. + +OpenCodex의 전역 Fast 설정은 **Auto**(`fastMode` 생략)로 유지하세요. +Fast 또는 Default 강제 모드는 Flex 선택을 덮어씁니다. `ocx sync` 후 Codex의 +모델 목록을 갱신하고 속도 선택기에서 **Flex**를 선택하거나, Codex CLI에서 +`/model --speed=flex`를 사용하면 됩니다. 이는 **선택 가능한 등급**이지 +모델별 강제 기본값은 아닙니다. 다른 모델로 바꾸더라도 Flex 사용이 자동 강제되지는 +않습니다. 상위 API로 `service_tier: "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..785b5f6314c 100644 --- a/src/codex/catalog/build-entries.ts +++ b/src/codex/catalog/build-entries.ts @@ -485,6 +485,8 @@ export interface ObservedCatalogMergeInput { readonly ultraFastTier?: boolean; /** Current captured wire declarations; disk rows and templates never populate this set. */ readonly configuredUltraFastSlugs?: ReadonlySet; + /** Current config-only verdict for persisted rows (not authority from the old disk tier). */ + readonly retainedFlexPolicyBySlug?: ReadonlyMap; readonly catalogModels: readonly RawEntry[]; readonly baselineCatalogModels: readonly RawEntry[]; readonly routedEntries: readonly RawEntry[]; @@ -532,6 +534,7 @@ export interface ObservedCatalogMergeInput { export function mergeCatalogEntriesFromObservedState({ ultraFastTier = false, configuredUltraFastSlugs = new Set(), + retainedFlexPolicyBySlug = new Map(), catalogModels, baselineCatalogModels, routedEntries, @@ -889,6 +892,25 @@ export function mergeCatalogEntriesFromObservedState({ || trustedAccountBoundNativeCatalogSlug(entry) !== undefined) continue; applyConfiguredUltraFast(entry, operatorRows.get(entry.slug), configuredUltraFastSlugs.has(entry.slug), ultraFastTier); } + // Degraded discovery preserves disk rows, so they bypass fresh tier generation. + // Revoke only an explicitly disallowed Flex tier according to today's provider policy; + // a transient outage must not erase Flex while the configured wire still permits it. + const persistedRoutedSet = new Set(preservedRoutedEntries); + for (const entry of finalRoutedEntries) { + if (!persistedRoutedSet.has(entry)) continue; + const slug = typeof entry.slug === "string" ? entry.slug : ""; + const excluded = isNativeAliasCatalogEntry(entry) || isExactComboCatalogEntry(entry, exactComboSlugs); + if (!excluded && retainedFlexPolicyBySlug.get(slug) !== false) continue; + 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"); + } + for (const field of ["service_tier", "default_service_tier"] as const) { + if (entry[field] === "flex") delete entry[field]; + } + } const finalRoutedEntrySet = new Set(finalRoutedEntries); const degradedPreservedCount = preservedRoutedEntries.filter(entry => { if (!finalRoutedEntrySet.has(entry)) return false; diff --git a/src/codex/catalog/configured-tiers.ts b/src/codex/catalog/configured-tiers.ts index 7eb723550b2..9cbe3cbfa90 100644 --- a/src/codex/catalog/configured-tiers.ts +++ b/src/codex/catalog/configured-tiers.ts @@ -1,10 +1,21 @@ import { COMBO_NAMESPACE } from "../../combos"; +import type { OcxProviderConfig } from "../../types"; +import { decodeRoutedModelId } from "../../providers/slug-codec"; +import { fastPolicyForModel } from "../../providers/service-tier"; import type { ResolvedFastPolicy } from "../../providers/fastwire"; import { catalogModelSlug, type CatalogModel, type RawEntry } from "./parsing"; import { preserveOperatorUltraFastTiers } from "./operator-tiers"; const MANAGED_ULTRAFAST = "opencodex_managed_ultrafast"; +/** Advertise Flex only when the final route can forward the caller's exact flex tier. */ +export function supportsConfiguredFlex(policy: ResolvedFastPolicy | undefined): boolean { + return policy?.eligibility === "eligible" + && policy.fastWire?.kind === "service-tier" + && policy.fastWire.canonicalToWire.flex === "flex" + && policy.forwardCallerTier; +} + /** 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" @@ -48,3 +59,62 @@ export function applyConfiguredUltraFast( // Advertising a selectable capability does not opt the user into its increased usage. entry[MANAGED_ULTRAFAST] = entry.slug; } + +/** + * Re-evaluate persisted routed Flex choices using current provider configuration. Degraded + * discovery keeps disk rows but cannot let a withdrawn wire declaration keep old picker tiers. + * Only rows tied to a configured provider are judged; unmanaged foreign rows are untouched. + */ +export function retainedFlexPolicyBySlug( + providers: Readonly>, + rows: readonly RawEntry[], + observedModels: readonly CatalogModel[], +): ReadonlyMap { + const result = new Map(); + const currentModels = new Map(); + for (const model of observedModels) { + const slug = catalogModelSlug(model); + const candidates = currentModels.get(slug) ?? []; + candidates.push(model); + currentModels.set(slug, candidates); + } + for (const row of rows) { + const slug = typeof row.slug === "string" ? row.slug : ""; + const slash = slug.indexOf("/"); + if (slash <= 0) continue; + const namespace = slug.slice(0, slash); + const owner = Object.entries(providers).find(([name, provider]) => + name === namespace || provider.alias === namespace); + if (!owner) continue; + const [name, provider] = owner; + if (provider.disabled === true || namespace === COMBO_NAMESPACE || name === COMBO_NAMESPACE) { + result.set(slug, false); + continue; + } + const observed = currentModels.get(slug); + if (observed?.length) { + // The current gather re-applies captured policy hints; conflicting rows fail closed. + result.set(slug, observed.every(model => + model.provider !== COMBO_NAMESPACE && model.nativeAlias !== true + && model.supportsServiceTier === true && model.supportsFlexTier === true)); + continue; + } + const requested = slug.slice(slash + 1); + const aliased = Object.entries(provider.modelAliases ?? {}) + .filter(([, alias]) => alias === requested).map(([id]) => id); + // A non-unique short alias is not authority for a capability declaration. + if (aliased.length > 1) { + result.set(slug, false); + continue; + } + const knownIds = [ + ...(provider.models ?? []), + ...Object.keys(provider.modelSupportsServiceTier ?? {}), + ...Object.keys(provider.modelAliases ?? {}), + ...Object.keys(provider.modelAdapters ?? {}), + ]; + const modelId = aliased[0] ?? decodeRoutedModelId(requested, knownIds); + result.set(slug, supportsConfiguredFlex(fastPolicyForModel(provider, modelId, name))); + } + return result; +} diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 87f9dd0cde2..1af507ceebc 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -184,6 +184,8 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel) entry.supports_reasoning_summaries = model.supportsReasoningSummaries; } if (model.supportsServiceTier === true) { + const eligibleRoutedFlex = model.supportsFlexTier === true + && model.provider !== COMBO_NAMESPACE && model.nativeAlias !== true; const nativeFast = model.codexForwardNativeCapabilityAlias && Array.isArray(entry.service_tiers) ? entry.service_tiers.find(tier => tier?.id === "priority") : undefined; @@ -192,8 +194,12 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel) id: "priority", name: "Fast", description: model.fastTierDescription ?? nativeFast?.description ?? "1.5x speed, increased usage", - }]; - entry.additional_speed_tiers = ["fast"]; + }, ...(eligibleRoutedFlex ? [{ + id: "flex", + name: "Flex", + description: "Capacity-dependent processing; availability and latency may vary", + }] : [])]; + entry.additional_speed_tiers = eligibleRoutedFlex ? ["fast", "flex"] : ["fast"]; } stampCapabilityProvenance(entry, model); } 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..ec4de232a52 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; + /** Explicitly verified exact Flex wire and foreign caller-tier forwarding on this route. */ + 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..5f959ac3ee9 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 { configuredUltraFastSlugs, retainedFlexPolicyBySlug } 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), + retainedFlexPolicyBySlug: retainedFlexPolicyBySlug(config.providers ?? {}, catalogModelsForMerge, 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..99662a71aa9 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 { configuredUltraFastSlugs, retainedFlexPolicyBySlug } 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), + retainedFlexPolicyBySlug: retainedFlexPolicyBySlug(config.providers ?? {}, catalogModels, ordered), modelPickerOrder, accountSelectors, catalogModels, diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 01018f767e8..03ff18fd027 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -2043,7 +2043,7 @@ describe("provider discovered model display names", () => { maxOutputTokens: 500_000, // Classified by the 2026-09-13 OAuth-lane probe: grok-4.6 is Fast-eligible on // either xAI transport, so the hint fills the capability and its description. - supportsServiceTier: true, supportsUltraFastTier: false, + supportsServiceTier: true, supportsUltraFastTier: false, supportsFlexTier: false, fastTierDescription: "Priority processing; tier pricing applies on key auth only", }); expect(catalogModelSlug(output)).toBe("xai/grok-4.6"); diff --git a/tests/codex-integration/configured-ultrafast-picker.test.ts b/tests/codex-integration/configured-ultrafast-picker.test.ts index 2afd05a3763..17999c05c55 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 { configuredUltraFastSlugs, retainedFlexPolicyBySlug } 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"; @@ -118,6 +118,65 @@ describe("configured Ultra Fast in the native Codex speed picker", () => { expect(merge([{ ...operator[0], default_service_tier: "ultrafast" }])[0]!.default_service_tier).toBeNull(); }); + test("degraded retained rows drop revoked Flex but preserve authorized Flex", () => { + const stored = merge(); + const flex = { id: "flex", name: "Flex" }; + stored[0]!.service_tiers.push(flex); + stored[0]!.additional_speed_tiers.push("flex"); + const configured = provider({ + fastWire: { kind: "service-tier", canonicalToWire: { priority: "priority", flex: "flex" }, foreignCallerTiers: "verbatim" }, + }); + const run = (active: typeof configured) => merge(stored, { + routedEntries: [], degradedProviderNames: new Set(["gateway"]), + configuredUltraFastSlugs: new Set(), + retainedFlexPolicyBySlug: retainedFlexPolicyBySlug({ gateway: active }, stored, []), + })[0]!; + expect(run(configured).additional_speed_tiers).toContain("flex"); + const revoked = run(provider()); + expect(revoked.service_tiers.map((tier: { id: string }) => tier.id)).not.toContain("flex"); + expect(revoked.additional_speed_tiers).not.toContain("flex"); + expect(run(provider({ modelSupportsServiceTier: { "task-model": false } })).additional_speed_tiers) + .not.toContain("flex"); + expect(stored[0]!.additional_speed_tiers).toContain("flex"); + }); + + test("retained Flex policy excludes physical combo providers and combo aliases without discovery", () => { + const flexProvider = provider({ + fastWire: { kind: "service-tier", canonicalToWire: { priority: "priority", flex: "flex" }, foreignCallerTiers: "verbatim" }, + }); + const check = (providers: Record, rowSlug: string) => + retainedFlexPolicyBySlug(providers, [{ slug: rowSlug }], []).get(rowSlug); + + expect(check({ combo: flexProvider }, "combo/task-model")).toBe(false); + expect(check({ combo: { ...flexProvider, alias: "bundle" } }, "bundle/task-model")).toBe(false); + expect(check({ gateway: { ...flexProvider, alias: "combo" } }, "combo/task-model")).toBe(false); + expect(check({ gateway: flexProvider }, "gateway/task-model")).toBe(true); + }); + + test("degraded retained encoded slugs resolve adapter-only native IDs before Flex policy", () => { + const rowSlug = "gateway/a-b"; + const base = provider({ + models: [], + supportsServiceTier: true, + modelSupportsServiceTier: {}, + modelAdapters: { "a/b": "openai-chat" }, + fastWire: { + kind: "service-tier", canonicalToWire: { priority: "priority", flex: "flex" }, + foreignCallerTiers: "verbatim", + }, + }); + const retainedPolicy = (config: OcxProviderConfig) => + retainedFlexPolicyBySlug({ gateway: config }, [{ slug: rowSlug }], []).get(rowSlug); + + // Without decoding, a-b incorrectly inherits the provider's Responses adapter + // and permits Flex, although the native a/b Chat route cannot forward that tier. + expect(fastPolicyForModel(base, "a-b", "gateway").forwardCallerTier).toBe(true); + expect(fastPolicyForModel(base, "a/b", "gateway").forwardCallerTier).toBe(false); + expect(retainedPolicy(base)).toBe(false); + // A verified Chat service-tier opt-in makes that exact native route eligible. + expect(retainedPolicy({ ...base, chatServiceTier: true })).toBe(true); + }); + test("selection and disabled-model policy still remove the entire row", () => { expect(merge([], { disabledModels: new Set([slug]) })).toEqual([]); expect(merge([], { selectedModelsByProvider: new Map([["gateway", new Set()]]) })).toEqual([]); diff --git a/tests/codex-integration/ultrafast-live-catalog.test.ts b/tests/codex-integration/ultrafast-live-catalog.test.ts index 8df2ecc7897..cb7c145225a 100644 --- a/tests/codex-integration/ultrafast-live-catalog.test.ts +++ b/tests/codex-integration/ultrafast-live-catalog.test.ts @@ -133,3 +133,50 @@ test("the real HTTP catalog repeatedly publishes only the declared routed tier", } } finally { rmSync(root, { recursive: true, force: true }); } }, 45_000); + +describe("declared Flex choice on routed models", () => { + const flexWire: OcxProviderConfig["fastWire"] = { + kind: "service-tier", + canonicalToWire: { priority: "priority", flex: "flex" }, + foreignCallerTiers: "verbatim", + }; + + test("advertises Flex in both live and derived catalogs only for an eligible final route", () => { + const declared = model({ fastWire: flexWire }); + const before = structuredClone(declared); + const row = build([declared]).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(); + expect(build([declared]).find(entry => entry.slug === slug)).toEqual(row); + expect(declared).toEqual(before); + }); + + test("ordinary Fast, unavailable wire, and explicit model opt-out never advertise Flex", () => { + const candidates = [ + model({ fastWire: undefined }), + model({ fastWire: { kind: "service-tier", canonicalToWire: { priority: "priority", flex: "priority" }, foreignCallerTiers: "verbatim" } }), + model({ fastWire: flexWire, modelSupportsServiceTier: { "task-model": false } }), + model({ fastWire: { ...flexWire!, foreignCallerTiers: "drop" } }), + ]; + for (const candidate of candidates) { + const row = build([candidate]).find(entry => entry.slug === slug)!; + expect(row.service_tiers?.some((tier: { id: string }) => tier.id === "flex") ?? false).toBe(false); + expect(row.additional_speed_tiers ?? []).not.toContain("flex"); + } + }); + + test("a provider's declared Flex does not leak into unrelated native or combo rows", () => { + const declared = model({ fastWire: flexWire }); + const combo = { ...declared, provider: "combo", id: "task-combo", alias: "friendly-combo", nativeAlias: true }; + const rows = buildCatalogEntries(null, ["gpt-6.1-sol"], [declared, combo], undefined, false, "default", + new Set(["friendly-combo"]), ["main"]); + expect(rows.find(row => row.slug === slug)!.additional_speed_tiers).toContain("flex"); + for (const id of ["friendly-combo", "gpt-6.1-sol", "main/gpt-6.1-sol"]) { + const row = rows.find(row => row.slug === id)!; + expect(row).toBeDefined(); + expect(row.service_tiers?.some((tier: { id: string }) => tier.id === "flex") ?? false).toBe(false); + expect(row.additional_speed_tiers ?? []).not.toContain("flex"); + } + }); +}); diff --git a/tests/routing/fastwire-policy.test.ts b/tests/routing/fastwire-policy.test.ts index f74b004d800..71bb8ca131b 100644 --- a/tests/routing/fastwire-policy.test.ts +++ b/tests/routing/fastwire-policy.test.ts @@ -749,3 +749,25 @@ describe("Responses TierDecision immutability", () => { else expect(outbound.service_tier).toBe(expected); }); }); + +describe("explicit Flex forwarding", () => { + test("eligible Responses wire forwards caller-selected flex, preserving Fast and default behavior", () => { + const policy = resolveFastPolicy({ + ...authorityForMatrix({ + source: "provider-adapter", declaration: "explicit", overrideAllowed: false, + capability: "true", chatForeignTierForward: true, + }), + providerAdapter: "openai-responses", + fastWireDeclaration: { + kind: "service-tier", + canonicalToWire: { priority: "priority", flex: "flex" }, + foreignCallerTiers: "verbatim", + }, + }, MODEL); + const decision = decideTier(policy, undefined, "flex"); + expect(decision).toEqual({ kind: "forward-caller" }); + expect(tierValueAfterDecision(decision, "flex")).toBe("flex"); + expect(tierValueAfterDecision(decideTier(policy, true, "flex"), "flex")).toBe("priority"); + expect(tierValueAfterDecision(decideTier(policy, false, "flex"), "flex")).toBeUndefined(); + }); +});