Skip to content
46 changes: 41 additions & 5 deletions devlog/_plan/260804_router_intelligence/001_pr_stack_status.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ head SHA, PR number/URL, verification result, and review state.
- Bun: `1.3.14`; package version: `2.10.0`
- Worktree: `D:\codex-worktrees\ocx-router-intelligence`
- Push remote: `origin` (Wibias/opencodex); PR target: `lidge-jun/opencodex:dev`
- Programme stack: #1003 (RI-01) and #1004 (RI-02) merged to `dev`; #1005 (RI-03) open.
- Programme stack: #1003 (RI-01), #1004 (RI-02), and #1005 (RI-03) merged to
`dev`; #1011 (RI-04) open.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Related in-flight PRs (not superseded by this stack)

Expand Down Expand Up @@ -41,8 +42,8 @@ other; closing one is a maintainer decision and neither is stale.
|---|---|---|---|---|---|---|
| RI-01 | `feat/ri-01-route-decision-traces` | `e44d234f0` | `b5a8e7c4c` | #1003 | https://github.com/lidge-jun/opencodex/pull/1003 | MERGED |
| RI-02 | `feat/ri-02-request-history-index` | `dev` (post-#1003 merge) | `2a72aa4a9` | #1004 | https://github.com/lidge-jun/opencodex/pull/1004 | MERGED |
| RI-03 | `feat/ri-03-routing-analytics` | `dev` (post-#1004 merge) | pending | #1005 | https://github.com/lidge-jun/opencodex/pull/1005 | OPEN (resync) |
| RI-04 | `feat/ri-04-policy-profile-core` | `feat/ri-03` head | pending | pending | pending | queued |
| RI-03 | `feat/ri-03-routing-analytics` | `dev` (post-#1004 merge) | `a594938c5` | #1005 | https://github.com/lidge-jun/opencodex/pull/1005 | MERGED |
| RI-04 | `feat/ri-04-policy-profile-core` | `dev` (post-#1005 merge) | `31c9f0b28` | #1011 | https://github.com/lidge-jun/opencodex/pull/1011 | OPEN (resync) |
| RI-05 | `feat/ri-05-capability-aware-routing` | `feat/ri-04` head | pending | pending | pending | queued |
| RI-06 | `feat/ri-06-health-aware-routing` | `feat/ri-05` head | pending | pending | pending | queued |
| RI-07 | `feat/ri-07-quota-aware-routing` | `feat/ri-06` head | pending | pending | pending | queued |
Expand Down Expand Up @@ -131,8 +132,9 @@ other; closing one is a maintainer decision and neither is stale.
(2) SQL column names are snake_case - analytics SELECT now aliases to
camelCase; (3) cost field is `estimate.cost.total` (CostBreakdown), not
`costUsd`; plus the row-cap is injectable for truncation tests.
- Final commit: `5f464c730` (CodeRabbit: cooldown parse gate, API `limit` default 5k, devlog + tests)
- PR: #1005 (OPEN) https://github.com/lidge-jun/opencodex/pull/1005
- Final commit: `5f464c730` (CodeRabbit: cooldown parse gate, API `limit` default 5k, devlog + tests);
merged head on `dev`: `a594938c5`
- PR: #1005 (MERGED) https://github.com/lidge-jun/opencodex/pull/1005
- Verification:
- `bun x tsc --noEmit`: PASSED (0 errors)
- `bun run test tests/routing-analytics.test.ts`: 10/10 pass:
Expand All @@ -143,3 +145,37 @@ other; closing one is a maintainer decision and neither is stale.
- Focused regression suites: 144/144 pass across 6 files
- `bun run privacy:scan`: passed
- Remaining Low findings: none

### RI-04 - feat/ri-04-policy-profile-core

- Base SHA: `a594938c5` (`dev` after #1005 merge; rebased from the RI-03 head
when #1003/#1004/#1005 landed)
- Reviewed commits: `63924495e` (RI-04 core) + review round `d478b393`
(request-evidence wiring), `8e1f1c3d` (provider-namespace alias check, dead
export removal), `aa9212fa` (CLI cleanup), `27511bf7` (absent-flag
semantics, cost-limit enforcement, deterministic id ordering, CodeRabbit
round)
- Findings (self-review): 4 fixed pre-push - (1) `serviceTier` evidence type
was `Unknownable` (number|boolean) but service tiers are strings - trace
type narrowed to `string | "unknown"`; (2) alias validation missed the
reserved `combo/` namespace prefix; (3) trace candidates did not carry
`score` - added `score` to `TraceCandidateInput`/`buildCandidate`;
(4) test expectation for weight normalization used wrong math (unspecified
weights keep defaults; sum 4.35 not 4).
- Final commit: `27511bf7` (see Reviewed commits)
- PR: #1011 (OPEN, ready) https://github.com/lidge-jun/opencodex/pull/1011
- Verification:
- `bun x tsc --noEmit`: PASSED (0 errors)
- `bun run test tests/routing-profile.test.ts`: 14/14 pass (validation,
normalization, revision digest, collisions incl. provider namespace,
config load, id/alias resolution, dry-run eligibility incl. request
evidence and cost limit, unknown/tie-break, API list+dry-run,
API error codes)
- Focused regression suites: pass across route-decision-trace,
routing-analytics, request-history-index, combos, codex-routing,
internal-cli-dispatch
- `bun run privacy:scan`: passed
- `tests/config.test.ts`: 109/115 pass; the 6 symlink failures reproduce
identically on the pristine base (Windows symlink EPERM, environmental)
- Remaining Low findings: none (B3/B5 residual: no-eligible trace names
candidate 0 as `selected`; API evidence fields are permissively dropped)
73 changes: 73 additions & 0 deletions docs-site/src/content/docs/reference/configuration/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Routing turns the model id sent by a client into one concrete provider and upstr
| --- | --- | --- | --- |
| `defaultProvider` | `string` | `"openai"` | Final provider used when no earlier model rule matches. It must name an enabled configured provider. |
| `combos?` | `Record<string, OcxComboConfig>` | `{}` | Virtual `combo/<id>` models built from ordered provider/model targets. |
| `routingProfiles?` | `Record<string, OcxRoutingProfileConfig>` | `{}` | Virtual `policy/<id>` models that select among an explicit candidate allowlist using hard capability requirements and deterministic scoring. |
Comment thread
Wibias marked this conversation as resolved.

## Model resolution order

Expand Down Expand Up @@ -82,6 +83,78 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`,
For strategy behavior, retryable failures, cooldowns, encrypted v2 task limits, and management
commands, see [Combos](/guides/combos/).

## Routing policy profiles (`config.routingProfiles`)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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.

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,
the `<provider>/<model>` routing namespace, combos, codex account namespaces, the `policy/`
namespace, or reserved bare native families (`gpt-*`, `o1-*`, `o3-*`, `o4-*`, `codex-*`).

| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `candidates` | `{ provider: string; model: string }[]` | required | Explicit allowlist of `provider/model` refs. No implicit expansion. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `alias?` | `string` | — | Optional public model id in place of `policy/<id>`. |
| `require?` | object | `{}` | Hard capability requirements evaluated before scoring (see below). |
| `optimize?` | object | latency 0.55, health 0.25, cost 0.10, quota 0.10 | Scoring weights; normalized deterministically. |
| `limits?` | object | — | Hard limits, e.g. `maxEstimatedCostUsd` (enforced by the dry-run evaluator when candidate cost evidence is known). |
| `unknownEvidence?` | object | capability `exclude`, health/quota/cost `penalize` | How unknown evidence is treated per dimension: `allow`, `penalize`, or `exclude`. Unknown never becomes zero. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.

`require` supports: `minContextWindow` (positive integer), and the booleans `tools`, `imageInput`,
`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.

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`).

```json
{
"routingProfiles": {
"fast": {
"alias": "ocx/fast",
"candidates": [
{ "provider": "anthropic", "model": "claude-sonnet-5" },
{ "provider": "openai", "model": "gpt-5.6-sol" }
],
"require": { "tools": true, "minContextWindow": 128000 },
"optimize": { "latency": 0.55, "health": 0.25, "cost": 0.10, "quota": 0.10 },
"limits": { "maxEstimatedCostUsd": 0.50 },
"unknownEvidence": {
"capability": "exclude",
"health": "penalize",
"quota": "penalize",
"cost": "penalize"
}
}
}
}
```

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.

### Combos vs policy profiles

- A **combo** is explicit ordered/weighted target routing and failover: the configured order (or
smooth weighted round-robin) decides, and failures advance through the list.
- A **policy profile** is evidence-based selection among configured candidates: hard capability
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).

### Catalog eligibility

A combo remains directly routable even when it cannot be listed. `ocx sync`, `/v1/models`, and the
Expand Down
13 changes: 9 additions & 4 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1007,13 +1007,18 @@ switch (command) {
break;
}
case "route": {
if (args[1] !== "combo") {
console.error("Usage: ocx route combo <subcommand>");
if (args[1] !== "combo" && args[1] !== "policy") {
console.error("Usage: ocx route <combo|policy> <subcommand>");
process.exitCode = 2;
break;
}
const { handleComboCommand } = await import("./combo");
process.exitCode = await handleComboCommand(args.slice(2));
if (args[1] === "combo") {
const { handleComboCommand } = await import("./combo");
process.exitCode = await handleComboCommand(args.slice(2));
} else {
const { handleRoutePolicyCommand } = await import("./route-policy");
process.exitCode = await handleRoutePolicyCommand(args.slice(2));
}
break;
}
case "agent": {
Expand Down
90 changes: 90 additions & 0 deletions src/cli/route-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
CliUsageError,
printData,
rejectArgs,
runCliAction,
runtimeRequest,
takeFlag,
takeIntegerOption,
type RuntimeApiDeps,
} from "./runtime-api";

const USAGE = `Usage:
ocx route policy list [--json]
ocx route policy show <id> [--json]
ocx route policy dry-run <id> [--model-context <tokens>] [--tools]
[--image] [--structured-output] [--json]`;

interface ProfileRow {
id?: string;
model?: string;
revision?: string;
}

async function list(argv: string[], deps: RuntimeApiDeps): Promise<void> {
const args = [...argv];
const wantsJson = takeFlag(args, "--json");
rejectArgs(args, USAGE);
const result = await runtimeRequest<{ profiles?: ProfileRow[] }>("/api/routing-profiles", {}, deps);
const rows = result.profiles ?? [];
printData(
result,
wantsJson,
rows.length
? rows.map(row => `${String(row.id)} ${String(row.model ?? `policy/${row.id}`)} rev:${String(row.revision ?? "-")}`)
: ["No routing profiles configured."],
);
}

async function show(argv: string[], deps: RuntimeApiDeps): Promise<void> {
const args = [...argv];
const id = args.shift();
const wantsJson = takeFlag(args, "--json");
if (!id) throw new CliUsageError("profile id is required", USAGE);
rejectArgs(args, USAGE);
const result = await runtimeRequest<{ profiles?: ProfileRow[] }>("/api/routing-profiles", {}, deps);
const profile = (result.profiles ?? []).find(candidate => candidate.id === id);
if (!profile) throw new CliUsageError(`unknown routing profile: ${id}`, USAGE);
printData(profile, wantsJson);
}

async function dryRun(argv: string[], deps: RuntimeApiDeps): Promise<void> {
const args = [...argv];
const id = args.shift();
const wantsJson = takeFlag(args, "--json");
if (!id) throw new CliUsageError("profile id is required", USAGE);
const modelContext = takeIntegerOption(args, "--model-context", { min: 1 });
const tools = takeFlag(args, "--tools");
const image = takeFlag(args, "--image");
const structuredOutput = takeFlag(args, "--structured-output");
rejectArgs(args, USAGE);
const result = await runtimeRequest(
"/api/routing-profiles/dry-run",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
profile: id,
evidence: {
...(modelContext !== undefined ? { contextWindow: modelContext } : {}),
...(tools ? { toolsRequired: true } : {}),
...(image ? { imageInputRequired: true } : {}),
...(structuredOutput ? { structuredOutputRequired: true } : {}),
},
}),
},
deps,
);
printData(result, wantsJson);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export async function handleRoutePolicyCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
return runCliAction(async () => {
const [sub, ...rest] = argv;
if (!sub) throw new CliUsageError("route policy requires a subcommand (list, show, dry-run)", USAGE);
if (sub === "list") await list(rest, deps);
else if (sub === "show") await show(rest, deps);
else if (sub === "dry-run") await dryRun(rest, deps);
else throw new CliUsageError(`unknown route policy command: ${sub}`, USAGE);
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
22 changes: 22 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
} from "./codex/account-namespace-match";
import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types";
import { routingProfileIssues } from "./routing/profile";
import {
forgetEphemeralSecretPath,
hardenSecretDir,
Expand Down Expand Up @@ -1261,6 +1262,27 @@ const configSchema = z.object({
}
}
}
const routingProfiles = (config as { routingProfiles?: unknown }).routingProfiles;
if (routingProfiles !== undefined) {
if (!routingProfiles || typeof routingProfiles !== "object" || Array.isArray(routingProfiles)) {
ctx.addIssue({ code: "custom", path: ["routingProfiles"], message: "routingProfiles must be an object" });
} else {
for (const [id, raw] of Object.entries(routingProfiles as Record<string, unknown>)) {
for (const issue of routingProfileIssues(id, raw, {
providers: config.providers,
combos: combos as Record<string, import("./types").OcxComboConfig> | undefined,
routingProfiles: routingProfiles as Record<string, import("./types").OcxRoutingProfileConfig>,
codexAccountNamespaces: accountNamespaces,
}, { excludeProfileId: id })) {
ctx.addIssue({
code: "custom",
path: ["routingProfiles", id, ...issue.path],
message: issue.message,
});
}
}
}
}
});

/**
Expand Down
Loading
Loading