Skip to content
Merged
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
41 changes: 38 additions & 3 deletions devlog/_plan/260804_router_intelligence/001_pr_stack_status.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ 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) | `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-04 | `feat/ri-04-policy-profile-core` | `dev` (post-#1005 merge) | `31c9f0b28` | #1011 | https://github.com/lidge-jun/opencodex/pull/1011 | MERGED |
| RI-05 | `feat/ri-05-capability-aware-routing` | `dev` (post-#1011 merge) | `088194a3a` | #1012 | https://github.com/lidge-jun/opencodex/pull/1012 | MERGED |
| RI-06 | `feat/ri-06-health-aware-routing` | `dev` (post-#1012 merge) | `af692bb7a` | #1013 | https://github.com/lidge-jun/opencodex/pull/1013 | in progress |
| RI-07 | `feat/ri-07-quota-aware-routing` | `feat/ri-06` head | pending | pending | pending | queued |
| RI-08 | `feat/ri-08-cost-aware-routing` | `feat/ri-07` head | pending | pending | pending | queued |
| RI-09 | `feat/ri-09-route-explainability-api` | `feat/ri-08` head | pending | pending | pending | queued |
Expand Down Expand Up @@ -179,3 +179,38 @@ other; closing one is a maintainer decision and neither is stale.
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)

### RI-05 - feat/ri-05-capability-aware-routing

- PR: #1012 (MERGED) https://github.com/lidge-jun/opencodex/pull/1012
- Merged on `dev` at `088194a3a` (2026-08-05). Includes the RI-05 execution
wiring, request-evidence hardening, no-eligible trace persistence, and
policy-namespace reservation.

### RI-06 - feat/ri-06-health-aware-routing

- Base SHA: `56f17f45c` (RI-05 head); PR #1013 https://github.com/lidge-jun/opencodex/pull/1013.
- Findings (self-review): 4 fixed pre-push - (1) route-time health evidence
needed synchronous index access (`openRequestHistoryIndexSync`); (2)
unknown-health "penalize" folds a deterministic 0.3 floor; (3) trace
candidates carry capability/health/quota/cost evidence; (4) score
assertions updated for the health component.
- Full-review round (verdict `changes-requested`, all items fixed):
1. indexer: dev/ino identity (already on dev) + row validation + clean-tail
`lastError`; appends are a tail, never a rebuild;
2. router: live Codex pool cooldown/soft-avoid evidence for `openai`
targets (`codexPoolHealthEvidence` + active account);
3. health: combo/failover `attempts[]` expand into per-target samples;
4. request evidence: nested message `content` arrays walked for images
(already on dev via #1012);
5. evaluator: request-side `contextWindow`/`structuredOutputRequired`/
`encryptedCodexTask` enforced (already on dev via #1012);
6. dry-run API: omitted `candidates` populate execution-equivalent
evidence;
7. alias validation rejects first-segment provider aliases;
8. `policy/<id>` without a profile falls through to normal resolution;
9. capability: adapter-level tool inference for tool-capable adapters.
- 8 bot threads fixed + resolved; `tsc --noEmit` 0 errors; routing suites
green; `privacy:scan` passed.
- Sync: merged `dev` (post-#1012, `088194a3a`) into the branch so the head is
mergeable and CI can run; base sync of the stack continues with RI-07.
20 changes: 14 additions & 6 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
import { getRoutingProfile, resolvePolicyProfileId } from "./routing/profile";
import { evaluatePolicyProfile, type PolicyRequestEvidence } from "./routing/evaluator";
import { candidateCapabilityEvidence } from "./routing/capability";
import { policyCandidateHealthEvidence } from "./routing/health";

export class NoEligiblePolicyCandidateError extends Error {
/** Evaluation trace (with per-candidate exclusions) when nothing qualified. */
Expand Down Expand Up @@ -489,17 +490,24 @@ function routeModelInternal(
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}`);
// selected candidate. Only explicit requests reach this branch; concrete
// recursive targets skip policy resolution entirely (bypassCombos) so an
// alias matching a selected candidate can never recurse, and a
// `policy/<id>` without a configured profile falls through to normal
// provider/default resolution instead of failing.
const policyId = !bypassCombos ? resolvePolicyProfileId(config, modelId) : null;
const profile = policyId ? getRoutingProfile(config, policyId) : undefined;
if (profile && policyId) {
// One clock read per decision keeps candidate evidence, exclusions, and
// scores mutually consistent and reproducible.
const now = Date.now();
const candidateEvidence = profile.candidates.map(candidate => ({
provider: candidate.provider,
model: candidate.model,
capability: candidateCapabilityEvidence(config, candidate.provider, candidate.model),
health: policyCandidateHealthEvidence(config, candidate, now),
}));
const evaluation = evaluatePolicyProfile(config, policyId, policyEvidence ?? {}, candidateEvidence);
const evaluation = evaluatePolicyProfile(config, policyId, policyEvidence ?? {}, candidateEvidence, now);
if (evaluation.selectedIndex === null) {
throw new NoEligiblePolicyCandidateError(policyId, evaluation.trace);
}
Expand Down
30 changes: 26 additions & 4 deletions src/routing/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,23 @@ function classifyHostname(hostname: string): "local" | "private" | null {
return null;
}

/**
* Adapters whose upstream protocol supports function/tool calling. Mirrors
* the adapter ids the resolver accepts (including the `azure` alias for
* `azure-openai`); `kiro` and `mimo-free` send/delegate tool calls.
*/
const TOOL_CAPABLE_ADAPTERS = new Set([
"openai-chat",
"openai-responses",
"anthropic",
"cursor",
"google",
"azure-openai",
"azure",
"kiro",
"mimo-free",
]);

function localRemoteEvidence(baseUrl: string | undefined): Pick<RouteCapabilityEvidence, "localOnly" | "remoteAllowed"> {
if (typeof baseUrl !== "string" || baseUrl.length === 0) return {};
try {
Expand Down Expand Up @@ -144,11 +161,16 @@ export function candidateCapabilityEvidence(
: 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.
// The catalog `capabilities` list is a positive per-model signal; a row
// without "tools" is treated as unknown, never as a negative. Without a
// catalog row the adapter protocol itself is the signal: tool-capable
// adapters run single tool calls even when the parallel-call opt-in is
// unset or false. `parallelToolCalls` stays a positive provider-level
// override.
const tools = capabilities.includes("tools")
|| (isNative ? true : provider?.parallelToolCalls === true)
|| isNative
|| (catalogRow === undefined && provider !== undefined && TOOL_CAPABLE_ADAPTERS.has(provider.adapter))
|| provider?.parallelToolCalls === true
|| undefined;

const reasoningEfforts = provider?.modelReasoningEfforts?.[modelId]
Expand Down
44 changes: 39 additions & 5 deletions src/routing/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ import {
type Unknownable,
} from "./trace";
import { getRoutingProfile, policyModelId, type NormalizedRoutingProfile } from "./profile";
import { healthScore } from "./health";

/** Unknown health under "penalize": a low-but-not-zero deterministic floor. */
export const HEALTH_UNKNOWN_PENALTY_SCORE = 0.3;
/** Unknown health under "allow": neutral midpoint of the [0,1] health scale. */
export const HEALTH_UNKNOWN_NEUTRAL_SCORE = 0.5;

export interface PolicyRequestEvidence {
/** Required context window for this request (tokens). */
Expand Down Expand Up @@ -238,6 +244,7 @@ export function evaluatePolicyProfile(
profileId: string,
requestEvidence: PolicyRequestEvidence,
candidateEvidence: PolicyCandidateEvidence[],
now = Date.now(),
): PolicyEvaluationResult {
const profile = getRoutingProfile(config, profileId);
if (!profile) throw new Error(`Unknown routing profile: ${profileId}`);
Expand Down Expand Up @@ -279,13 +286,36 @@ export function evaluatePolicyProfile(
if (overCostLimit) {
exclusions.push({ code: "cost-limit", detail: "maxEstimatedCostUsd" });
}
const eligible = !unsatisfied && !excludedByUnknown && !overCostLimit;
let eligible = !unsatisfied && !excludedByUnknown && !overCostLimit;

// Health scoring (RI-06): live hard cooldown is authoritative and
// excludes; unknown health follows the profile's unknownEvidence policy;
// historical health never overrides explicit ineligibility.
const health = evidence.health;
let healthValue = health ? healthScore(health, now) : null;
if (health?.cooldownUntilMs !== undefined && health.cooldownUntilMs > now) {
exclusions.push({ code: "cooldown" });
eligible = false;
} else if (healthValue === null && profile.unknownEvidence.health === "exclude") {
exclusions.push({ code: "unknown-health" });
eligible = false;
} else if (healthValue === null && profile.unknownEvidence.health === "penalize") {
healthValue = HEALTH_UNKNOWN_PENALTY_SCORE;
} else if (healthValue === null && profile.unknownEvidence.health === "allow") {
// Neutral midpoint: blending keeps an unknown candidate from outranking
// a measured one with the same configured priority.
healthValue = HEALTH_UNKNOWN_NEUTRAL_SCORE;
}

const priorityScore = configuredPriorityScore(index, profile.candidates.length);
const score: RouteScoreEvidence = {
total: priorityScore,
components: { configuredPriority: priorityScore },
};
const healthWeight = profile.optimize.health;
const components: RouteScoreEvidence["components"] = { configuredPriority: priorityScore };
let total = priorityScore;
if (healthWeight > 0 && healthValue !== null) {
total = priorityScore * (1 - healthWeight) + healthValue * healthWeight;
components.health = healthValue;
}
const score: RouteScoreEvidence = { total, components };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const evaluated: PolicyEvaluationCandidate = {
provider: evidence.provider,
model: evidence.model,
Expand Down Expand Up @@ -321,6 +351,10 @@ export function evaluatePolicyProfile(
eligible: candidate.eligible,
exclusions: candidate.exclusions,
...(candidate.score ? { score: candidate.score } : {}),
...(candidate.capability ? { capability: candidate.capability } : {}),
...(candidate.health ? { health: candidate.health } : {}),
...(candidate.quota ? { quota: candidate.quota } : {}),
...(candidate.cost ? { cost: candidate.cost } : {}),
})),
selected: selectedIndex === null
? { provider: candidates[0]?.provider ?? "", model: candidates[0]?.model ?? "", reason: "no-eligible-candidate" }
Expand Down
Loading
Loading