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 95116237ff..598e5550da 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -165,8 +165,12 @@ But the model catalog and runtime request tier id use `priority`. opencodex pres Native OpenAI passthrough models keep fast support; routed providers are capability-gated — `service_tier` is stripped only when the provider declares `supportsServiceTier: false` (the registry classifies canonical OpenAI as `true`, DeepSeek and Volcengine Ark as `false`), while unclassified -custom gateways keep caller-supplied values untouched and never get an injection. The fast option is -never advertised where it cannot be honored, and custom gateways can opt in explicitly with `true`. +custom gateways keep caller-supplied values untouched and never get an injection. A custom gateway +can opt in globally with `supportsServiceTier: true`, or narrowly with +`modelSupportsServiceTier: { "verified-model": true }`; an exact `false` narrows a provider +default of `true`, while provider-level `false` remains fail-closed. The same final adapter/model +decision controls both catalog metadata and runtime injection, so the fast option is never +advertised where it cannot be honored. ## Subagent selection diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 62203e285d..13ae1d5c93 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -68,6 +68,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; collision-safe key presets preserve an older same-named custom destination. | | `responsesPath?` | `string` | Relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no scheme, query, or fragment. | | `supportsServiceTier?` | `boolean` | Tri-state `service_tier` capability. `true`: fast mode may inject and caller values are preserved. `false`: the field is stripped and never injected (the upstream documented as not supporting it must not receive it). Absent: the provider is unclassified — caller-supplied values are preserved untouched and fast mode never injects. The registry classifies canonical OpenAI (`true`), DeepSeek, and Volcengine Ark (`false`); set it explicitly only for custom gateways that genuinely support tiers. | +| `modelSupportsServiceTier?` | `Record` | Exact upstream model capability overrides. An exact `false` can narrow a provider default of `true`; an explicit provider-level `false` remains fail-closed and cannot be reopened by the map. Only an exact model declared `true` on an `openai-chat` or `openai-responses` route publishes Fast metadata and receives global Fast injection. Unknown models remain unclassified. Management `PATCH /api/providers` merges entries and accepts `null` to clear one. | | `preserveResponsesReasoningContent?` | `boolean` | Keep plaintext reasoning content on replayed Responses reasoning items instead of blanking it (blanking is the ChatGPT backend's rule). Enable for upstreams whose contract accepts reasoning replay, such as DeepSeek. Proxy-minted `ocxr1` envelopes are always stripped. | | `disabled?` | `boolean` | Keep the provider on disk but exclude it from routing and model/catalog listings. | | `apiKey?` | `string` | API key, or an `${ENV_VAR}` / `$ENV_VAR` reference resolved at request time. | diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 99b62af5e5..09bfddb348 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -744,6 +744,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd messages, stream: parsed.stream, }; + // Preserve a caller-selected service tier for OpenAI-compatible chat gateways. The + // request pipeline deliberately does not inject fast mode for this adapter, but dropping + // an explicit value here makes the Responses parser's serviceTier projection ineffective. + if (parsed.options.serviceTier !== undefined) body.service_tier = parsed.options.serviceTier; if (modelInList(provider.reasoningSplitModels, parsed.modelId)) body.reasoning_split = true; const maxTokens = resolveMaxTokens(provider, parsed); const openRouterRouting = resolveOpenRouterRouting(provider, parsed.modelId); diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index 1031f073a1..13be1997b1 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -173,6 +173,11 @@ export function deriveComboCatalogModel( ...(members.every(member => member.parallelToolCalls === true) ? { parallelToolCalls: true } : {}), + ...(members.every(member => member.supportsServiceTier === true) + ? { supportsServiceTier: true } + : members.some(member => member.supportsServiceTier === false) + ? { supportsServiceTier: false } + : {}), ...(members.some(member => member.supportsReasoningSummaries === false) ? { supportsReasoningSummaries: false } : {}), }; } diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 3a7f823d5c..59e7fdd44b 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -138,6 +138,15 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel) if (typeof model.supportsReasoningSummaries === "boolean") { entry.supports_reasoning_summaries = model.supportsReasoningSummaries; } + if (model.supportsServiceTier === true) { + entry.default_service_tier = null; + entry.service_tiers = [{ + id: "priority", + name: "Fast", + description: "1.5x speed, increased usage", + }]; + entry.additional_speed_tiers = ["fast"]; + } } export function applyReasoningLevels( diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 7d69b811d5..077e39b3ad 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -118,6 +118,8 @@ export interface CatalogModel { parallelToolCalls?: boolean; /** Whether Codex may send Responses text.verbosity for this routed model. */ supportsVerbosity?: boolean; + /** Whether this exact routed model has a verified OpenAI-compatible service tier. */ + supportsServiceTier?: boolean; supportsReasoningSummaries?: boolean; /** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */ capabilities?: string[]; @@ -390,7 +392,7 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = delete entry.service_tiers; delete entry.default_service_tier; // Routed rows cloned from native templates must not inherit OpenAI-only summary delivery. - // Per-model routed opt-ins can be added once provider metadata exposes this capability. + // Explicit provider/model metadata is re-applied after this normalization step. delete entry.supports_reasoning_summaries; const isCursorEntry = typeof entry.slug === "string" && entry.slug.startsWith("cursor/"); // Routed providers use opencodex sidecars and client-executed tool discovery. The sidecar diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index a723fe0878..d17872dbcb 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -32,6 +32,7 @@ import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; +import { canForwardServiceTierForModel, supportsServiceTierForModel } from "../../providers/service-tier"; import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry"; import { parseAntigravityAvailableModels } from "../../providers/antigravity-models"; import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; @@ -552,6 +553,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco defRe: prov.modelDefaultReasoningEfforts ?? null, rsSum: prov.modelSupportsReasoningSummaries ?? null, rsDel: prov.modelReasoningSummaryDelivery ?? null, + serviceTier: prov.modelSupportsServiceTier ?? null, noVis: [...(prov.noVisionModels ?? [])].sort(), ptc: prov.parallelToolCalls ?? null, gMode: prov.googleMode ?? null, @@ -623,8 +625,13 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, const reasoningEfforts = configuredReasoningEfforts(prov, model.id); const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort; const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id); + const supportsServiceTier = supportsServiceTierForModel(prov, model.id); + const serviceTierSupported = typeof supportsServiceTier === "boolean" + ? canForwardServiceTierForModel(prov, model.id, name) + : undefined; + const { supportsServiceTier: _staleServiceTier, ...modelWithoutServiceTier } = model; const hinted = { - ...model, + ...modelWithoutServiceTier, ...(configuredCap !== undefined ? { contextWindow: typeof model.contextWindow === "number" && model.contextWindow > 0 @@ -643,6 +650,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, : {}), ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), + ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier: serviceTierSupported } : {}), ...(prov.adapter === "kiro" ? { supportsVerbosity: false } : {}), // Default-on for openai-chat providers (explicit false opts out); other adapters // advertise only on explicit opt-in. @@ -1714,6 +1722,7 @@ async function gatherRoutedModelsUncached( const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model])); const customModels = (config.customModels ?? []).map(cm => { const rawProvider = config.providers[cm.provider]; + const effectiveProvider = enrichedByName.get(cm.provider) ?? rawProvider; const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId); const base: CatalogModel = { id: cm.modelId, @@ -1724,6 +1733,11 @@ async function gatherRoutedModelsUncached( ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}), ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), + ...(effectiveProvider && typeof supportsServiceTierForModel(effectiveProvider, cm.modelId) === "boolean" + ? { + supportsServiceTier: canForwardServiceTierForModel(effectiveProvider, cm.modelId, cm.provider), + } + : {}), }; // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls, diff --git a/src/config.ts b/src/config.ts index 7f99b2e470..6f38098d31 100644 --- a/src/config.ts +++ b/src/config.ts @@ -627,6 +627,7 @@ const providerConfigSchema = z.object({ statelessResponses: z.boolean().optional(), requiresAdjacentResponsesToolResults: z.boolean().optional(), supportsServiceTier: z.boolean().optional(), + modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), preserveResponsesReasoningContent: z.boolean().optional(), allowPrivateNetwork: z.boolean().optional(), noStructuredOutputModels: z.array(z.string().min(1)) @@ -1398,6 +1399,17 @@ const configSchema = z.object({ message: reasoningSummariesError, }); } + const serviceTierModelsError = booleanRecordConfigError( + (provider as { modelSupportsServiceTier?: unknown }).modelSupportsServiceTier, + "modelSupportsServiceTier", + ); + if (serviceTierModelsError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelSupportsServiceTier"], + message: serviceTierModelsError, + }); + } const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError( (provider as { modelReasoningSummaryDelivery?: unknown }).modelReasoningSummaryDelivery, (provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries, diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 452eed56db..1c5d30a789 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -358,6 +358,17 @@ function applyReasoningSummaryDefaults( }; } +function applyServiceTierModelDefaults( + prov: OcxProviderConfig, + defaults: Readonly> | undefined, +): void { + if (!defaults) return; + prov.modelSupportsServiceTier = { + ...defaults, + ...(prov.modelSupportsServiceTier ?? {}), + }; +} + /** * Last-resort enrichment for a provider whose NAME matches no registry id. * @@ -385,6 +396,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig // which vendor endpoint is this row talking to — and is already restricted to fixed key // destinations, so a templated or overridable base URL cannot be claimed by it. enrichReasoningSummariesByDestination(prov); + applyServiceTierModelDefaults(prov, registryEntryForProviderDestination(prov)?.modelSupportsServiceTier); return; } const explicitDirectReasoning: DirectReasoningEffortOverrides = { @@ -431,6 +443,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.supportsServiceTier === undefined && entry.supportsServiceTier !== undefined) prov.supportsServiceTier = entry.supportsServiceTier; if (prov.preserveResponsesReasoningContent === undefined && entry.preserveResponsesReasoningContent !== undefined) prov.preserveResponsesReasoningContent = entry.preserveResponsesReasoningContent; applyReasoningSummaryDefaults(prov, entry.modelSupportsReasoningSummaries); + applyServiceTierModelDefaults(prov, entry.modelSupportsServiceTier); // Registry-only repair policy (#938): fill only when the runtime provider has // no explicit policy, and deep-clone so saved/user values never alias the // registry constant. diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 42eef9369d..e165b15807 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -206,6 +206,8 @@ export interface ProviderRegistryEntry { * (and the canonical openai seed comparison keeps its exact key set). */ supportsServiceTier?: boolean; + /** Registry default for exact model service-tier capability; explicit config keys win. */ + modelSupportsServiceTier?: Record; /** Registry default for plaintext reasoning replay; see `OcxProviderConfig.preserveResponsesReasoningContent`. Registry-only like `supportsServiceTier`. */ preserveResponsesReasoningContent?: boolean; /** Registry defaults for per-model Codex reasoning propagation; explicit user keys win during enrichment. */ diff --git a/src/providers/service-tier.ts b/src/providers/service-tier.ts new file mode 100644 index 0000000000..49ebe22baa --- /dev/null +++ b/src/providers/service-tier.ts @@ -0,0 +1,95 @@ +import type { OcxProviderConfig } from "../types"; +import { MODEL_ADAPTER_OVERRIDE_ALLOWED } from "../types"; +import { providerModelWireDefault, type InboundWire } from "./registry"; + +/** OpenAI-compatible adapters that can carry the standard `service_tier` field. */ +export const SERVICE_TIER_ADAPTERS = new Set(["openai-chat", "openai-responses"]); + +type ServiceTierCapabilityProvider = Pick< + OcxProviderConfig, + "adapter" | "supportsServiceTier" | "modelSupportsServiceTier" | "modelAdapters" | "baseUrl" | "authMode" +>; + +/** + * Read a model map by exact model identity. Service-tier capability is deliberately + * stricter than the older model metadata maps: a family key or a colon-qualified + * fallback must not silently advertise Fast for a sibling model that was never verified. + * A case-insensitive exact match keeps hand-edited ids consistent with the other maps + * without widening the model scope. + */ +function exactModelValue( + record: Record | undefined, + modelId: string, +): T | undefined { + if (!record) return undefined; + if (Object.prototype.hasOwnProperty.call(record, modelId)) return record[modelId]; + const folded = modelId.toLowerCase(); + for (const [key, value] of Object.entries(record)) { + if (key.toLowerCase() === folded) return value; + } + return undefined; +} + +/** + * Resolve the declared provider/model capability. An explicit provider-level false is a + * fail-closed boundary and cannot be reopened by a model map. Otherwise an exact model + * declaration wins over the provider default, including an explicit false. The resolver is + * provider-local: the caller must first resolve the final provider, so identical bare model ids + * on two providers cannot share capability state. + */ +export function supportsServiceTierForModel( + provider: Pick, + modelId: string, +): boolean | undefined { + if (provider.supportsServiceTier === false) return false; + return exactModelValue(provider.modelSupportsServiceTier, modelId) + ?? provider.supportsServiceTier; +} + +/** Resolve an explicit model wire override for catalog-time capability projection. */ +export function serviceTierAdapterForModel( + providerName: string, + provider: Pick, + modelId: string, + inbound: InboundWire = "responses", +): string { + // Keep this lookup identical to resolveWireProtocolOverride(): configured model-adapter + // entries are exact-case keys, while registry defaults intentionally normalize ids there. + const configured = provider.modelAdapters?.[modelId]; + if (configured !== undefined && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(configured)) return configured; + return providerModelWireDefault( + providerName, + provider, + modelId, + MODEL_ADAPTER_OVERRIDE_ALLOWED, + inbound, + ) ?? provider.adapter; +} + +/** Whether the final provider/model pair can actually publish/send OpenAI service tiers. */ +export function canForwardServiceTierForModel( + provider: ServiceTierCapabilityProvider, + modelId: string, + providerName?: string, + inbound: InboundWire = "responses", +): boolean { + return serviceTierSupportForModel(provider, modelId, providerName, inbound) === true; +} + +/** + * Return the tri-state capability after resolving the model's final wire adapter. + * `false` means either an explicit provider/model denial or an adapter that cannot carry the + * field; `undefined` keeps the existing conservative contract for an unclassified OpenAI wire. + */ +export function serviceTierSupportForModel( + provider: ServiceTierCapabilityProvider, + modelId: string, + providerName?: string, + inbound: InboundWire = "responses", +): boolean | undefined { + const adapter = providerName === undefined + ? provider.adapter + : serviceTierAdapterForModel(providerName, provider, modelId, inbound); + if (!SERVICE_TIER_ADAPTERS.has(adapter)) return false; + return supportsServiceTierForModel(provider, modelId); +} diff --git a/src/router.ts b/src/router.ts index 435148f3f9..36ef48b8a7 100644 --- a/src/router.ts +++ b/src/router.ts @@ -99,6 +99,7 @@ export function knownModelIdsForProvider(provName: string, prov: OcxProviderConf registry?.modelDefaultReasoningEfforts, registry?.modelReasoningEffortMap, registry?.modelMaxOutputTokens, + registry?.modelSupportsServiceTier, ]) { for (const id of Object.keys(map ?? {})) ids.add(id); } @@ -275,6 +276,10 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ? mergePositiveNumberCaps(registryEntry.modelMaxInputTokens, provider.modelMaxInputTokens) : mergeRecordFill(registryEntry.modelMaxInputTokens, provider.modelMaxInputTokens); const modelMaxOutputTokens = mergeRecordFill(registryEntry.modelMaxOutputTokens, provider.modelMaxOutputTokens); + const modelSupportsServiceTier = mergeRecordFill( + registryEntry.modelSupportsServiceTier, + provider.modelSupportsServiceTier, + ); const noVisionModels = mergeStringArray(registryEntry.noVisionModels, provider.noVisionModels); const noReasoningModels = mergeStringArray(registryEntry.noReasoningModels, provider.noReasoningModels); const noTemperatureModels = mergeStringArray(registryEntry.noTemperatureModels, provider.noTemperatureModels); @@ -360,6 +365,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(modelInputModalities ? { modelInputModalities } : {}), ...(modelMaxInputTokens ? { modelMaxInputTokens } : {}), ...(modelMaxOutputTokens ? { modelMaxOutputTokens } : {}), + ...(modelSupportsServiceTier ? { modelSupportsServiceTier } : {}), ...(modelReasoningEfforts ? { modelReasoningEfforts } : {}), ...(modelDefaultReasoningEfforts ? { modelDefaultReasoningEfforts } : {}), ...(reasoningEffortMap ? { reasoningEffortMap } : {}), diff --git a/src/routing/capability.ts b/src/routing/capability.ts index b02083f4fd..291b5204fc 100644 --- a/src/routing/capability.ts +++ b/src/routing/capability.ts @@ -12,6 +12,7 @@ import type { OcxConfig } from "../types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import { SERVICE_TIER_ADAPTERS, supportsServiceTierForModel } from "../providers/service-tier"; import { applyProviderContextCap, providerContextCap } from "../providers/context-cap"; import { PROVIDER_REGISTRY } from "../providers/registry"; import { @@ -185,9 +186,12 @@ export function candidateCapabilityEvidence( ?? catalogRow?.reasoningEfforts ?? (isNative ? nativeReasoningEfforts(modelId) : undefined); - const tierSupport = provider?.supportsServiceTier - ?? registryEntry?.supportsServiceTier; + const tierSupport = (provider ? supportsServiceTierForModel(provider, modelId) : undefined) + ?? (registryEntry ? supportsServiceTierForModel(registryEntry, modelId) : undefined); + const tierAdapter = provider?.adapter ?? registryEntry?.adapter; const serviceTier = tierSupport === true + && tierAdapter !== undefined + && SERVICE_TIER_ADAPTERS.has(tierAdapter) ? "supported" : tierSupport === false ? "unsupported" : "unknown"; diff --git a/src/routing/compatibility/behavior.ts b/src/routing/compatibility/behavior.ts index 9a3f8f2242..1fe8f80ee3 100644 --- a/src/routing/compatibility/behavior.ts +++ b/src/routing/compatibility/behavior.ts @@ -1,5 +1,6 @@ import type { OcxConfig, OcxProviderConfig } from "../../types"; import { PROVIDER_REGISTRY, type ProviderAuthKind } from "../../providers/registry"; +import { supportsServiceTierForModel } from "../../providers/service-tier"; import { localFingerprint } from "../../lab/digest"; import type { LabBehaviorSource, LabBehaviorValues } from "../../lab/live/types"; @@ -116,7 +117,10 @@ export function resolveProductionBehaviorValues( "auth.mode": behaviorRow("provider_config", authMode), "auth.transport": behaviorRow("provider_config", authTransportFor(effective, adapter, authMode)), "responses.stateful": behaviorRow("provider_config", effective.statelessResponses !== true), - "responses.serviceTier": behaviorRow("provider_config", effective.supportsServiceTier ?? null), + "responses.serviceTier": behaviorRow( + "provider_config", + supportsServiceTierForModel(effective, modelId) ?? null, + ), "responses.snapshotRepair": behaviorRow("provider_config", effective.responsesSnapshotRepair === true), "responses.itemIdRepair": behaviorRow("provider_config", effective.responsesItemIdRepair ?? null), "limits.contextWindow": behaviorRow( diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 0e3cffc67e..bf31a2ba7a 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -76,6 +76,7 @@ import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from ". import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; +import { withProviderServiceTierDTO } from "./provider-capability-config"; import { applySystemEnvToggle } from "../system-env"; import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache"; import { runWindowsTrayAction } from "../windows-tray-control"; @@ -111,7 +112,7 @@ async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{ export async function handleConfigRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; if (url.pathname === "/api/config" && req.method === "GET") { - return jsonResponse(safeConfigDTO(config)); + return jsonResponse(withProviderServiceTierDTO(safeConfigDTO(config), config)); } if (url.pathname === "/api/config" && req.method === "PUT") { diff --git a/src/server/management/provider-capability-config.ts b/src/server/management/provider-capability-config.ts new file mode 100644 index 0000000000..ce58966df3 --- /dev/null +++ b/src/server/management/provider-capability-config.ts @@ -0,0 +1,48 @@ +import { booleanRecordConfigError } from "../../config"; +import type { OcxConfig } from "../../types"; + +/** + * Provider-management validation that belongs to the provider editor, not the + * request-authentication/CORS boundary. Keeping this here lets the editor + * evolve its capability schema without widening the auth-cors security surface. + */ +export function providerServiceTierConfigError(name: unknown, provider: unknown): string | null { + if (typeof name !== "string" || !provider || typeof provider !== "object" || Array.isArray(provider)) { + return null; + } + const error = booleanRecordConfigError( + (provider as { modelSupportsServiceTier?: unknown }).modelSupportsServiceTier, + "modelSupportsServiceTier", + ); + return error ? `provider ${name} ${error}` : null; +} + +function publicServiceTierRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const entries = Object.entries(value).filter(([model, supported]) => + model.trim().length > 0 && typeof supported === "boolean", + ); + return Object.fromEntries(entries) as Record; +} + +/** + * Add the provider editor's capability map to the already secret-free config + * DTO. `safeConfigDTO` remains the owner of auth/cors redaction; this helper + * only projects a boolean model capability used by the management UI. + */ +export function withProviderServiceTierDTO(dto: unknown, config: OcxConfig): unknown { + if (!dto || typeof dto !== "object" || Array.isArray(dto)) return dto; + const root = dto as { providers?: unknown }; + if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return dto; + + const providers = root.providers as Record; + const projectedProviders: Record = { ...providers }; + for (const [name, provider] of Object.entries(config.providers)) { + const dtoProvider = providers[name]; + if (!dtoProvider || typeof dtoProvider !== "object" || Array.isArray(dtoProvider)) continue; + const capabilities = publicServiceTierRecord(provider.modelSupportsServiceTier); + if (capabilities === undefined) continue; + projectedProviders[name] = { ...(dtoProvider as Record), modelSupportsServiceTier: capabilities }; + } + return { ...root, providers: projectedProviders }; +} diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 76682c29b1..6bc900f36d 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -67,6 +67,7 @@ import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from ". import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; +import { providerServiceTierConfigError } from "./provider-capability-config"; import { applySystemEnvToggle } from "../system-env"; import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; @@ -207,6 +208,29 @@ function applyProviderPatchFields( } touched = true; } + if (Object.hasOwn(rawBody, "modelSupportsServiceTier")) { + const value = rawBody.modelSupportsServiceTier; + if (value === null) { + delete next.modelSupportsServiceTier; + } else { + if (!isPlainRecord(value)) return { error: "modelSupportsServiceTier must be a plain object or null" }; + const capabilities: Record = { ...(next.modelSupportsServiceTier ?? {}) }; + for (const [model, supported] of Object.entries(value)) { + if (!model.trim()) return { error: "modelSupportsServiceTier keys must be nonblank model ids" }; + if (supported === null) { + delete capabilities[model]; + continue; + } + if (typeof supported !== "boolean") { + return { error: "modelSupportsServiceTier values must be booleans or null" }; + } + capabilities[model] = supported; + } + if (Object.keys(capabilities).length > 0) next.modelSupportsServiceTier = capabilities; + else delete next.modelSupportsServiceTier; + } + touched = true; + } if (Object.hasOwn(rawBody, "noStructuredOutputModels")) { const value = rawBody.noStructuredOutputModels; if (value === null) { @@ -306,6 +330,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise).service_tier = tier; @@ -1075,8 +1083,15 @@ async function applyFinalRouteRequestNormalization(args: { } parsed.options.serviceTier = tier; } - applyServiceTierGate(route.provider, parsed._rawBody, parsed.options); - if (route.provider.adapter === "openai-responses" && route.provider.supportsServiceTier === false) { + applyServiceTierGate( + route.provider, + parsed._rawBody, + parsed.options, + route.modelId, + route.providerName, + inboundWire, + ); + if (modelServiceTierSupport === false) { logCtx.requestedServiceTier = undefined; logCtx.requestedSpeedLabel = undefined; } @@ -1130,10 +1145,9 @@ async function applyFinalRouteRequestNormalization(args: { } } recordAttemptRequestedEffort(logCtx); - logCtx.modelSupportsServiceTier = catalogModelSupportsServiceTier( - route.modelId, - logCtx.requestedServiceTier ?? logCtx.configuredServiceTier, - ); + logCtx.modelSupportsServiceTier = SERVICE_TIER_ADAPTERS.has(route.provider.adapter) + ? modelServiceTierSupport + : undefined; } @@ -1438,8 +1452,19 @@ export function applyServiceTierGate( provider: OcxProviderConfig, rawBody: unknown, options: { serviceTier?: string }, + modelId?: string, + providerName?: string, + inbound: InboundWire = "responses", ): void { - if (provider.adapter !== "openai-responses" || provider.supportsServiceTier !== false) return; + // A direct unit caller without a model id retains the historical tri-state behavior for + // adapters outside the OpenAI service-tier family. Once a model is known, resolve the final + // model adapter as well: an explicit override to Anthropic (or another non-OpenAI wire) must + // not carry a caller-supplied `service_tier` through a route that cannot forward it. + if (modelId === undefined && !SERVICE_TIER_ADAPTERS.has(provider.adapter)) return; + const support = modelId === undefined + ? provider.supportsServiceTier + : serviceTierSupportForModel(provider, modelId, providerName, inbound); + if (support !== false) return; if (rawBody && typeof rawBody === "object") { delete (rawBody as Record).service_tier; } diff --git a/src/types.ts b/src/types.ts index 3dc5c3354a..2418814d28 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1265,6 +1265,8 @@ export interface OcxProviderConfig { * An explicit config value always wins over the registry default. */ supportsServiceTier?: boolean; + /** Exact upstream model ids that override the provider-level service-tier capability. */ + modelSupportsServiceTier?: Record; /** * Responses upstream whose native contract accepts plaintext reasoning replay * (DeepSeek documents reasoning items with plaintext content). When set, the diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index d35f292728..e8e24eee03 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -14,7 +14,8 @@ and all account-selector clones and drop that model family from raw `/v1/models`); - applies exact provider/model compatibility exclusions after live discovery and metadata augmentation, so upstream-advertised but uncallable rows never enter dashboard or Codex pickers; -- strips native-only service tier and WebSocket metadata unless explicitly enabled; +- strips native-only service tier and WebSocket metadata unless the final routed provider/model + explicitly enables the verified OpenAI-compatible service tier; - backs up the pristine catalog once per catalog: the copy is keyed by a hash of the catalog path (`catalog-backup-.json`), and the legacy unsuffixed `catalog-backup.json` is retained in addition for the default catalog, so a restore resolves the backup for the catalog it is restoring diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 1b09e8fdde..9be57c6149 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -29,6 +29,16 @@ state. `openai-apikey` uses its configured key and canonical API base URL. Missi within their route; neither route falls through to the other. See [`08_openai-provider-tiers.md`](08_openai-provider-tiers.md). +### Routed service-tier capability + +OpenAI-compatible service-tier support is resolved only after the final provider/model wire is +known. `supportsServiceTier` remains the provider fallback, while the exact +`modelSupportsServiceTier` map can override it per upstream model, including an explicit `false`. +The catalog and request path share this decision: a routed row publishes `service_tiers` only when +the resolved `openai-chat` or `openai-responses` adapter is explicitly capable, and the final-route +normalizer applies the same gate to `service_tier`. Capability is namespaced by the selected provider +and model; model-name similarity and adapter type alone never opt a gateway in. + `POST /v1/responses/compact` handles remote compaction v1 before the generic `/v1/responses` branch and before the `/v1/*` guard. Unknown `/v1/*` paths return JSON 404 errors instead of falling through to GUI static serving. diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index e57b1481dc..9afff936fa 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2274,6 +2274,28 @@ describe("Codex catalog routed normalization", () => { expect(routed?.base_instructions).toContain("claude-sonnet-4-6"); expect(routed?.default_reasoning_level).toBe("medium"); }); + + test("buildCatalogEntries restores Fast metadata only for an explicit routed capability", () => { + const entries = buildCatalogEntries(nativeTemplate(), [], [ + { provider: "verified-relay", id: "gpt-5.6-sol", supportsServiceTier: true }, + { provider: "unverified-relay", id: "gpt-5.6-sol" }, + { provider: "blocked-relay", id: "gpt-5.6-sol", supportsServiceTier: false }, + ]); + const verified = entries.find(e => e.slug === "verified-relay/gpt-5.6-sol"); + const unverified = entries.find(e => e.slug === "unverified-relay/gpt-5.6-sol"); + const blocked = entries.find(e => e.slug === "blocked-relay/gpt-5.6-sol"); + + expect(verified?.service_tiers).toEqual([{ + id: "priority", + name: "Fast", + description: "1.5x speed, increased usage", + }]); + expect(verified?.additional_speed_tiers).toEqual(["fast"]); + expect(unverified).not.toHaveProperty("service_tiers"); + expect(unverified).not.toHaveProperty("additional_speed_tiers"); + expect(blocked).not.toHaveProperty("service_tiers"); + expect(blocked).not.toHaveProperty("additional_speed_tiers"); + }); test("buildCatalogEntries advertises parallel tool calls only for Cursor routed models", () => { const entries = buildCatalogEntries(nativeTemplate(), [], [ { provider: "cursor", id: "composer-2.5", owned_by: "cursor" }, diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 4bf9f8421e..f6a8cdeffb 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -30,6 +30,7 @@ import { } from "../src/server"; import { handleManagementAPI } from "../src/server/management-api"; import { providerManagementConfigError } from "../src/server/auth-cors"; +import { providerServiceTierConfigError, withProviderServiceTierDTO } from "../src/server/management/provider-capability-config"; import { clearModelCache, markProviderDiscoveryFailed } from "../src/codex/model-cache"; import type { OcxConfig } from "../src/types"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; @@ -125,6 +126,39 @@ afterEach(() => { }); describe("provider management validation", () => { + test("service-tier validation and public projection stay in the management boundary", () => { + expect(providerServiceTierConfigError("relay", { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + modelSupportsServiceTier: { verified: true, blocked: false }, + })).toBeNull(); + expect(providerServiceTierConfigError("relay", { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + modelSupportsServiceTier: { verified: "yes" }, + })).toContain("modelSupportsServiceTier.verified must be a boolean"); + + const config = { + providers: { + relay: { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + apiKey: "sk-never-project", + modelSupportsServiceTier: { verified: true }, + }, + }, + } as unknown as OcxConfig; + const dto = withProviderServiceTierDTO( + { providers: { relay: { hasApiKey: true } } }, + config, + ) as { providers: { relay: Record } }; + expect(dto.providers.relay).toMatchObject({ + hasApiKey: true, + modelSupportsServiceTier: { verified: true }, + }); + expect(JSON.stringify(dto)).not.toContain("sk-never-project"); + }); + test("validates and exposes structured-output model opt-outs", () => { const provider = { adapter: "openai-chat", @@ -2240,9 +2274,11 @@ describe("provider management validation", () => { adapter: "openai-chat", baseUrl: "https://relay.example.test/v1", apiKey: "sk-existing", + allowPrivateNetwork: true, models: ["wide", "narrow"], contextWindow: 256_000, modelContextWindows: { narrow: 64_000 }, + modelSupportsServiceTier: { narrow: false }, }, }, }; @@ -2269,20 +2305,24 @@ describe("provider management validation", () => { expect(rows.find(row => row.name === "relay")).toMatchObject({ contextWindow: 256_000, modelContextWindows: { narrow: 64_000 }, + modelSupportsServiceTier: { narrow: false }, }); const updated = await request("PATCH", { contextWindow: 350_000, modelContextWindows: { wide: 350_000 }, + modelSupportsServiceTier: { wide: true }, }); expect(updated?.status).toBe(200); expect(liveConfig.providers.relay).toMatchObject({ contextWindow: 350_000, modelContextWindows: { wide: 350_000, narrow: 64_000 }, + modelSupportsServiceTier: { wide: true, narrow: false }, }); expect(loadConfig().providers.relay).toMatchObject({ contextWindow: 350_000, modelContextWindows: { wide: 350_000, narrow: 64_000 }, + modelSupportsServiceTier: { wide: true, narrow: false }, }); for (const invalid of [ @@ -2295,24 +2335,32 @@ describe("provider management validation", () => { { modelContextWindows: { wide: 1e100 } }, { modelContextWindows: { "": 100_000 } }, { modelContextWindows: { wide: -1 } }, + { modelSupportsServiceTier: { wide: "yes" } }, + { modelSupportsServiceTier: { "": true } }, ]) { expect((await request("PATCH", invalid))?.status).toBe(400); } expect(liveConfig.providers.relay).toMatchObject({ contextWindow: 350_000, modelContextWindows: { wide: 350_000, narrow: 64_000 }, + modelSupportsServiceTier: { wide: true, narrow: false }, }); expect((await request("PATCH", { modelContextWindows: { wide: null } }))?.status).toBe(200); expect(liveConfig.providers.relay.modelContextWindows).toEqual({ narrow: 64_000 }); + expect((await request("PATCH", { modelSupportsServiceTier: { wide: null } }))?.status).toBe(200); + expect(liveConfig.providers.relay.modelSupportsServiceTier).toEqual({ narrow: false }); + const cleared = await request("PATCH", { contextWindow: null, modelContextWindows: null, + modelSupportsServiceTier: null, }); expect(cleared?.status).toBe(200); expect(liveConfig.providers.relay.contextWindow).toBeUndefined(); expect(liveConfig.providers.relay.modelContextWindows).toBeUndefined(); + expect(liveConfig.providers.relay.modelSupportsServiceTier).toBeUndefined(); }); test("provider PATCH manages custom headers with merge and clear semantics", async () => { diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index b78c3102b8..b22007a59f 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -302,6 +302,16 @@ describe("openai-chat credential hardening", () => { expect(body).not.toHaveProperty("prompt_cache_key"); }); + test("preserves a caller-supplied service tier for the outbound chat body", () => { + const adapter = createOpenAIChatAdapter(provider()); + const req = parsed(); + req.options.serviceTier = "priority"; + + const body = JSON.parse(adapter.buildRequest(req).body); + + expect(body.service_tier).toBe("priority"); + }); + test("canonical Kimi Coding Plan routes forward Codex prompt_cache_key", () => { for (const [providerName, authMode] of [ ["kimi", "oauth"], diff --git a/tests/service-tier-capability.test.ts b/tests/service-tier-capability.test.ts index cec418598d..b446dd3133 100644 --- a/tests/service-tier-capability.test.ts +++ b/tests/service-tier-capability.test.ts @@ -11,6 +11,8 @@ import { providerConfigSeed, enrichProviderFromRegistry } from "../src/providers import { getProviderRegistryEntry } from "../src/providers/registry"; import type { RequestLogContext } from "../src/server/request-log"; import { applyServiceTierGate, handleResponses } from "../src/server/responses/core"; +import { canForwardServiceTierForModel, supportsServiceTierForModel } from "../src/providers/service-tier"; +import { serviceTierAdapterForModel } from "../src/providers/service-tier"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; describe("registry capability reaches saved configs without overriding them", () => { @@ -43,6 +45,45 @@ describe("registry capability reaches saved configs without overriding them", () }); }); +describe("service-tier capability is exact-model and provider-scoped", () => { + test("an exact model entry overrides the provider fallback in both directions", () => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://relay.example.test/v1", + supportsServiceTier: true, + modelSupportsServiceTier: { "gpt-5.6-sol": false, "gpt-5.6-terra": true }, + }; + expect(supportsServiceTierForModel(provider, "gpt-5.6-sol")).toBe(false); + expect(supportsServiceTierForModel(provider, "gpt-5.6-terra")).toBe(true); + expect(supportsServiceTierForModel(provider, "gpt-5.6-luna")).toBe(true); + expect(supportsServiceTierForModel({ + supportsServiceTier: false, + modelSupportsServiceTier: { "gpt-5.6-sol": true }, + }, "gpt-5.6-sol")).toBe(false); + }); + + test("the same bare model id remains independent across providers", () => { + expect(supportsServiceTierForModel({ supportsServiceTier: true }, "same-model")).toBe(true); + expect(supportsServiceTierForModel({ supportsServiceTier: false }, "same-model")).toBe(false); + expect(supportsServiceTierForModel({ modelSupportsServiceTier: { "same-model": true } }, "same-model")).toBe(true); + }); + + test("catalog-time wire resolution follows exact model adapters", () => { + const provider = { + adapter: "openai-chat", + baseUrl: "https://relay.example.test/v1", + modelAdapters: { "responses-model": "openai-responses" }, + } as const; + expect(serviceTierAdapterForModel("custom-relay", provider, "responses-model")).toBe("openai-responses"); + expect(serviceTierAdapterForModel("custom-relay", provider, "chat-model")).toBe("openai-chat"); + expect(canForwardServiceTierForModel({ + ...provider, + adapter: "anthropic", + supportsServiceTier: true, + }, "anthropic-model", "custom-relay")).toBe(false); + }); +}); + describe("applyServiceTierGate fails closed", () => { test("a supported provider is untouched, including a caller-supplied tier", () => { const body = { model: "m", service_tier: "flex" }; @@ -76,6 +117,31 @@ describe("applyServiceTierGate fails closed", () => { applyServiceTierGate({ adapter: "openai-chat", baseUrl: "https://api.deepseek.com" }, body, options); expect(body.service_tier).toBe("priority"); }); + + test("an exact unsupported model strips a caller tier even when the provider default is true", () => { + const body = { model: "gpt-5.6-sol", service_tier: "priority" }; + const options: { serviceTier?: string } = { serviceTier: "priority" }; + applyServiceTierGate({ + adapter: "openai-chat", + baseUrl: "https://relay.example.test/v1", + supportsServiceTier: true, + modelSupportsServiceTier: { "gpt-5.6-sol": false }, + }, body, options, "gpt-5.6-sol"); + expect(body).not.toHaveProperty("service_tier"); + expect(options.serviceTier).toBeUndefined(); + }); + + test("a final non-service-tier adapter strips the caller tier after route resolution", () => { + const body = { model: "anthropic-model", service_tier: "priority" }; + const options: { serviceTier?: string } = { serviceTier: "priority" }; + applyServiceTierGate({ + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + supportsServiceTier: true, + }, body, options, "anthropic-model", undefined); + expect(body).not.toHaveProperty("service_tier"); + expect(options.serviceTier).toBeUndefined(); + }); }); describe("the gate fires on the live handleResponses path", () => { @@ -175,4 +241,18 @@ describe("the gate fires on the live handleResponses path", () => { const optedIn = await drive("custom-gw", { ...custom(), supportsServiceTier: true }, "some-model", { service_tier: "priority" }); expect(optedIn.service_tier).toBe("priority"); }); + + test("custom Chat provider injects Fast only for an explicitly capable model", async () => { + const custom = (): OcxProviderConfig => ({ + adapter: "openai-chat", + baseUrl: "https://gateway.example.com/v1", + apiKey: "sk-test", + supportsServiceTier: true, + modelSupportsServiceTier: { "verified-model": true, "blocked-model": false }, + }); + const supported = await drive("custom-chat", custom(), "verified-model", {}, true); + expect(supported.service_tier).toBe("priority"); + const blocked = await drive("custom-chat", custom(), "blocked-model", { service_tier: "priority" }, true); + expect(blocked).not.toHaveProperty("service_tier"); + }); });