Skip to content
49 changes: 29 additions & 20 deletions docs-site/src/content/docs/reference/configuration/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,18 @@ Routing turns the model id sent by a client into one concrete provider and upstr

opencodex resolves the requested model in this order:

1. A configured `<account-selector>/<native-openai-model>` namespace, routed through exactly the
1. An explicit `policy/<id>` or configured routing-profile alias, executing the policy evaluator
and routing the selected candidate. An unknown profile id fails closed.
2. A configured `<account-selector>/<native-openai-model>` namespace, routed through exactly the
mapped stored Codex account. An invalid or unavailable exact target fails closed.
2. A canonical `combo/<id>` or configured combo alias. Canonical ids win before alias matching.
3. An explicit `<provider>/<model>` namespace whose prefix names a configured provider.
4. A bare native OpenAI-family id such as `gpt-*`, `o1-*`, `o3-*`, or `o4-*`, routed through the
3. A canonical `combo/<id>` or configured combo alias. Canonical ids win before alias matching.
4. An explicit `<provider>/<model>` namespace whose prefix names a configured provider.
5. A bare native OpenAI-family id such as `gpt-*`, `o1-*`, `o3-*`, or `o4-*`, routed through the
canonical enabled `openai` provider.
5. An exact match for a provider's `defaultModel`.
6. A known provider-family model prefix.
7. An exact model in a provider's configured `models` list.
8. `defaultProvider`, preserving the requested model id.
6. An exact match for a provider's `defaultModel`.
7. A known provider-family model prefix.
8. An exact model in a provider's configured `models` list.
9. `defaultProvider`, preserving the requested model id.

Disabled providers are excluded. An explicit namespace for a disabled provider fails instead of
falling through. Provider entries are checked in their JSON insertion order for rules that can match
Expand Down Expand Up @@ -87,10 +89,10 @@ commands, see [Combos](/guides/combos/).

Routing policy profiles are the Router Intelligence selection layer: an explicitly requested
`policy/<id>` (or configured alias) selects among a fixed candidate allowlist using hard capability
requirements and deterministic, explainable scoring. In this release profiles are configuration and
dry-run evaluation only: production requests are not yet routed through them (execution wiring
arrives with RI-05), and existing model ids are **never** routed through a profile implicitly.
Policy ids do not participate in the model resolution order above until execution lands.
requirements and deterministic, explainable scoring. An explicit `policy/<id>` request (or a
configured alias) executes the evaluator and routes the selected candidate. Existing model ids are
**never** routed through a profile implicitly: the `policy/` namespace and profile aliases are the
only entry points, and both are validated against the model resolution order above.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Each key is an id matching `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`, always addressable as `policy/<id>`,
with one optional `alias`. Aliases must be unique and cannot collide with configured providers,
Expand All @@ -110,9 +112,15 @@ namespace, or reserved bare native families (`gpt-*`, `o1-*`, `o3-*`, `o4-*`, `c
`structuredOutput`, `localOnly`, `remoteAllowed`, `encryptedCodexTasks`; plus `reasoningEffort` and
`serviceTier` strings.

Request evidence supplied to a dry-run (context window, tools, image input, structured output,
reasoning effort, service tier, encrypted Codex tasks) is evaluated against candidate capabilities
together with the profile `require` block; a candidate must satisfy both to be eligible.
For `unknownEvidence.capability`, `penalize` currently behaves like `allow`: scoring has only a
configured-priority component until a capability score dimension ships (planned with RI-06+), so
`penalize` cannot yet change the selected candidate.

Request evidence is evaluated against candidate capabilities together with the profile `require`
block; a candidate must satisfy both to be eligible. On the live request path the proxy derives
tools and image-input evidence from the request body; context-window size and the remaining
evidence dimensions stay unknown at routing time. Use the dry-run API/CLI to inspect the full
evidence surface for context-sensitive profiles.

The CLI dry-run accepts request-evidence flags but cannot supply candidate capability evidence yet;
candidate evidence is provided through the API (`POST /api/routing-profiles/dry-run`).
Expand Down Expand Up @@ -140,9 +148,9 @@ candidate evidence is provided through the API (`POST /api/routing-profiles/dry-
}
```

CLI: `ocx route policy list`, `ocx route policy show <id>`, and
`ocx route policy dry-run <id> --model-context <tokens> --tools`. Dry-run evaluates candidates
without sending any upstream request.
CLI: `ocx route policy list [--json]`, `ocx route policy show <id> [--json]`, and
`ocx route policy dry-run <id> [--model-context <tokens>] [--tools] [--image] [--structured-output] [--json]`.
Dry-run evaluates candidates without sending any upstream request.

### Combos vs policy profiles

Expand All @@ -152,8 +160,9 @@ without sending any upstream request.
requirements filter first, then deterministic scoring ranks the survivors.

Both are virtual namespaces with aliases and collision validation; they differ in *how* a candidate
is chosen. Profile scoring will expand with capability (RI-05), health (RI-06), quota (RI-07), and
cost (RI-08) dimensions; per-request trace recording arrives with execution (RI-05).
is chosen. Profile scoring currently uses the configured-priority component only; health (RI-06),
quota (RI-07), and cost (RI-08) score dimensions are planned. Per-request route-decision traces are
recorded when a policy profile executes.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
### Catalog eligibility

Expand Down
15 changes: 13 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,18 @@ const providerConfigSchema = z.object({
responsesSnapshotRepair: z.boolean().optional(),
}).passthrough();

const RESERVED_PROVIDER_NAMES = new Set(["__proto__", "prototype", "constructor"]);
const RESERVED_PROVIDER_NAMES = new Set([
// JavaScript prototype-pollution guards.
"__proto__",
"prototype",
"constructor",
// System-reserved routing namespace (resolved before provider/account
// namespaces in routeModelInternal). "combo" is intentionally NOT reserved:
// a physical provider named `combo` is a supported pattern (combo aliases
// hosted on the combo provider), and the combo selector only wins when an
// actual combo id matches.
"policy",
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const PROVIDER_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/;
const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
const SENSITIVE_PROVIDER_HEADERS = new Set([
Expand Down Expand Up @@ -1062,7 +1073,7 @@ const configSchema = z.object({
ctx.addIssue({
code: "custom",
path: ["providers", name],
message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys",
message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys or routing namespaces (policy)",
});
}
const provider = config.providers[name];
Expand Down
59 changes: 55 additions & 4 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ import {
type RouteDecisionTraceV1,
type TraceCandidateInput,
} from "./routing/trace";
import { getRoutingProfile, resolvePolicyProfileId } from "./routing/profile";
import { evaluatePolicyProfile, type PolicyRequestEvidence } from "./routing/evaluator";
import { candidateCapabilityEvidence } from "./routing/capability";

export class NoEligiblePolicyCandidateError extends Error {
/** Evaluation trace (with per-candidate exclusions) when nothing qualified. */
readonly trace?: RouteDecisionTraceV1;

constructor(readonly profileId: string, trace?: RouteDecisionTraceV1) {
super(`No eligible candidates for policy profile: ${profileId}`);
this.name = "NoEligiblePolicyCandidateError";
this.trace = trace;
}
}

export interface RouteResult {
providerName: string;
Expand Down Expand Up @@ -466,8 +480,39 @@ function comboRouteCandidates(
});
}

function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: boolean): RouteResult {
function routeModelInternal(
config: OcxConfig,
modelId: string,
bypassCombos: boolean,
policyEvidence?: PolicyRequestEvidence,
): RouteResult {
const slash = modelId.indexOf("/");
// Policy namespace is system-reserved: an explicit `policy/<id>` or a
// configured profile alias executes the policy evaluator and routes the
// selected candidate. Only explicit requests reach this branch.
const policyId = resolvePolicyProfileId(config, modelId);
if (policyId) {
const profile = getRoutingProfile(config, policyId);
if (!profile) throw new Error(`Unknown routing profile: ${policyId}`);
Comment thread
Wibias marked this conversation as resolved.
const candidateEvidence = profile.candidates.map(candidate => ({
provider: candidate.provider,
model: candidate.model,
capability: candidateCapabilityEvidence(config, candidate.provider, candidate.model),
}));
const evaluation = evaluatePolicyProfile(config, policyId, policyEvidence ?? {}, candidateEvidence);
if (evaluation.selectedIndex === null) {
throw new NoEligiblePolicyCandidateError(policyId, evaluation.trace);
}
const selected = evaluation.candidates[evaluation.selectedIndex]!;
const concrete = `${selected.provider}/${selected.model}`;
const routed = routeModelInternal(config, concrete, true);
return {
...routed,
routeKind: "policy" as const,
routeReason: "policy-selected",
routeDecision: evaluation.trace,
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (slash > 0) {
const namespace = modelId.slice(0, slash);
const binding = codexAccountNamespaceEntries(config)
Expand Down Expand Up @@ -506,7 +551,7 @@ function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: bo
const concrete = `${combo.target.provider}/${combo.target.model}`;
// The selected target is already a concrete provider/model reference. Resolve it without
// consulting combo aliases again, otherwise an alias that shadows the target can recurse.
const routed = routeModelInternal(config, concrete, true);
const routed = routeModelInternal(config, concrete, true, undefined);
return { ...routed, combo, routeKind: "combo" as const, routeReason: "combo-pick" };
}
}
Expand Down Expand Up @@ -581,8 +626,14 @@ function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: bo
throw new Error(`No provider configured for model: ${modelId}`);
}

export function routeModel(config: OcxConfig, modelId: string): RouteResult {
const route = routeModelInternal(config, modelId, false);
export function routeModel(
config: OcxConfig,
modelId: string,
policyEvidence?: PolicyRequestEvidence,
): RouteResult {
const route = routeModelInternal(config, modelId, false, policyEvidence);
// Policy routes carry a full evaluation trace already; never rebuild it.
if (route.routeDecision) return route;
const accountRef = route.codexAccountNamespace;
const combo = route.combo ? getCombo(config, route.combo.comboId) : undefined;
route.routeDecision = buildRouteDecisionTrace({
Expand Down
179 changes: 179 additions & 0 deletions src/routing/capability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* Candidate capability evidence for policy routing (RI-05).
*
* Evidence comes from canonical local sources only - provider config maps,
* the provider registry, the cached Codex catalog file, and the native-model
* metadata helpers. No live network fetch happens at routing time.
*
* "Unknown is not zero": any dimension without canonical evidence stays
* `undefined` (unknown) and the profile's `unknownEvidence` policy decides
* how that affects eligibility.
*/

import type { OcxConfig } from "../types";
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
import { PROVIDER_REGISTRY } from "../providers/registry";
import {
nativeInputModalities,
nativeOpenAiContextWindow,
nativeParallelToolCalls,
nativeReasoningEfforts,
} from "../codex/catalog/metadata";
import { readCatalog, readCodexCatalogPath } from "../codex/catalog/parsing";
import { statSync } from "node:fs";
import type { RouteCapabilityEvidence } from "./trace";

type CatalogModelRow = {
provider: string;
id: string;
contextWindow?: number;
inputModalities?: string[];
reasoningEfforts?: string[];
capabilities?: string[];
};

/**
* Catalog rows memoized by path + mtime: the cached Codex catalog is stable
* between refreshes, and re-reading/parsing the whole file per candidate on
* the request path would multiply a synchronous disk + JSON cost by the
* profile candidate count for every policy-routed request.
*/
let catalogCache: { path: string; mtimeMs: number; rows: CatalogModelRow[] } | null = null;

function cachedCatalogModels(): CatalogModelRow[] {
try {
const path = readCodexCatalogPath();
const mtimeMs = statSync(path).mtimeMs;
if (catalogCache && catalogCache.path === path && catalogCache.mtimeMs === mtimeMs) {
return catalogCache.rows;
}
const catalog = readCatalog(path);
const models = catalog?.models;
if (!Array.isArray(models)) return [];
const rows = models
.filter((model): model is Record<string, unknown> & { id: string; provider: string } =>
typeof model === "object" && model !== null && typeof model.id === "string" && typeof model.provider === "string")
.map(model => ({
provider: model.provider,
id: model.id,
...(typeof model.contextWindow === "number" ? { contextWindow: model.contextWindow } : {}),
...(Array.isArray(model.inputModalities)
? { inputModalities: model.inputModalities.filter((value): value is string => typeof value === "string") }
: {}),
...(Array.isArray(model.reasoningEfforts)
? { reasoningEfforts: model.reasoningEfforts.filter((value): value is string => typeof value === "string") }
: {}),
...(Array.isArray(model.capabilities)
? { capabilities: model.capabilities.filter((value): value is string => typeof value === "string") }
: {}),
}));
catalogCache = { path, mtimeMs, rows };
return rows;
} catch {
return [];
}
}

/**
* Classify a hostname for locality evidence. `URL.hostname` keeps IPv6
* literals bracketed (`[::1]`), so strip the brackets before matching.
* Anything not positively local or private stays unknown: "unknown is not
* zero", so an unrecognized host must never assert `remoteAllowed`.
*/
function classifyHostname(hostname: string): "local" | "private" | null {
const host = hostname.trim().toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0") return "local";
if (host === "::1" || /^127\./.test(host)) return "local";
if (/^10\./.test(host)
|| /^192\.168\./.test(host)
|| /^169\.254\./.test(host)
|| /^172\.(1[6-9]|2\d|3[01])\./.test(host)
|| /^f[cd][0-9a-f]{2}:/.test(host)
|| /^fe80:/.test(host)
|| /^::ffff:(?:10\.|127\.|192\.168\.|169\.254\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(host)) {
return "private";
}
return null;
}

function localRemoteEvidence(baseUrl: string | undefined): Pick<RouteCapabilityEvidence, "localOnly" | "remoteAllowed"> {
if (typeof baseUrl !== "string" || baseUrl.length === 0) return {};
try {
const hostname = new URL(baseUrl).hostname;
if (!hostname) return {};
const kind = classifyHostname(hostname);
if (kind === null) return {};
// Both booleans are emitted once classified: definitive negative evidence,
// so a local host cannot satisfy `require.remoteAllowed` (or vice versa)
// under `unknownEvidence.capability: "allow"`/`"penalize"`.
return kind === "local" || kind === "private"
? { localOnly: true, remoteAllowed: false }
: { remoteAllowed: true, localOnly: false };
} catch {
return {};
}
}

/**
* Assemble canonical capability evidence for one `provider/model` candidate.
* Sources (in priority order): provider config maps, provider registry hints,
* cached Codex catalog row, native-model metadata.
*/
export function candidateCapabilityEvidence(
config: OcxConfig,
providerName: string,
modelId: string,
): RouteCapabilityEvidence {
const provider = config.providers[providerName];
const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName);
const catalogRow = cachedCatalogModels().find(model => model.provider === providerName && model.id === modelId);
const isNative = providerName === "openai" && !modelId.includes("/");

const contextWindow = provider?.modelContextWindows?.[modelId]
?? provider?.contextWindow
?? registryEntry?.modelContextWindows?.[modelId]
?? catalogRow?.contextWindow
?? (isNative ? nativeOpenAiContextWindow(modelId) : undefined);

const modalities = provider?.modelInputModalities?.[modelId]
?? registryEntry?.modelInputModalities?.[modelId]
?? catalogRow?.inputModalities
?? (isNative ? nativeInputModalities(modelId) : undefined);
const image = Array.isArray(modalities)
? modalities.includes("image")
: undefined;

const capabilities = catalogRow?.capabilities ?? [];
// `parallelToolCalls` is provider-level evidence that the provider accepts
// parallel tool calls (registry-set per provider); the catalog `capabilities`
// list is the per-model signal. Both are positive local evidence only.
const tools = capabilities.includes("tools")
|| (isNative ? true : provider?.parallelToolCalls === true)
|| undefined;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const reasoningEfforts = provider?.modelReasoningEfforts?.[modelId]
?? registryEntry?.modelReasoningEfforts?.[modelId]
?? catalogRow?.reasoningEfforts
?? (isNative ? nativeReasoningEfforts(modelId) : undefined);

const tierSupport = provider?.supportsServiceTier
?? registryEntry?.supportsServiceTier;
const serviceTier = tierSupport === true
? "supported"
: tierSupport === false ? "unsupported" : "unknown";

const localRemote = localRemoteEvidence(provider?.baseUrl);
const encryptedCodexTasks = isCanonicalOpenAiForwardProvider(
provider ?? { adapter: "", authMode: undefined, baseUrl: undefined },
);

return {
...(typeof contextWindow === "number" ? { contextWindow } : {}),
...(typeof image === "boolean" ? { image } : {}),
...(typeof tools === "boolean" ? { tools } : {}),
...(reasoningEfforts !== undefined && reasoningEfforts.length > 0 ? { reasoningEfforts } : {}),
...(serviceTier !== "unknown" ? { serviceTier } : {}),
...localRemote,
encryptedCodexTasks,
};
}
Loading
Loading