Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs-site/src/content/docs/guides/codex-app-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean>` | 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. |
Expand Down
4 changes: 4 additions & 0 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/codex/catalog/aggregation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
};
}
Expand Down
9 changes: 9 additions & 0 deletions src/codex/catalog/effort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion src/codex/catalog/parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions src/providers/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,17 @@ function applyReasoningSummaryDefaults(
};
}

function applyServiceTierModelDefaults(
prov: OcxProviderConfig,
defaults: Readonly<Record<string, boolean>> | undefined,
): void {
if (!defaults) return;
prov.modelSupportsServiceTier = {
...defaults,
...(prov.modelSupportsServiceTier ?? {}),
};
}

/**
* Last-resort enrichment for a provider whose NAME matches no registry id.
*
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean>;
/** 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. */
Expand Down
95 changes: 95 additions & 0 deletions src/providers/service-tier.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
record: Record<string, T> | 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<OcxProviderConfig, "supportsServiceTier" | "modelSupportsServiceTier">,
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<OcxProviderConfig, "adapter" | "baseUrl" | "authMode" | "modelAdapters">,
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);
}
6 changes: 6 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 } : {}),
Expand Down
Loading
Loading