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
30 changes: 27 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 @@ -46,9 +46,9 @@ other; closing one is a maintainer decision and neither is stale.
| 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 | MERGED |
| RI-07 | `feat/ri-07-quota-aware-routing` | `dev` (post-#1013 merge) | `0c5100271` (pre-restack) | #1014 | https://github.com/lidge-jun/opencodex/pull/1014 | in progress |
| 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 |
| RI-07 | `feat/ri-07-quota-aware-routing` | `dev` (post-#1013 merge) | `1f07c00b8` | #1014 | https://github.com/lidge-jun/opencodex/pull/1014 | MERGED |
| RI-08 | `feat/ri-08-cost-aware-routing` | `dev` (post-#1014 merge) | `410db97e4` | #1015 | https://github.com/lidge-jun/opencodex/pull/1015 | MERGED |
| RI-09 | `feat/ri-09-route-explainability-api` | `dev` (post-#1015 merge) | `d887c1202` | #1016 | https://github.com/lidge-jun/opencodex/pull/1016 | rebased / review in progress |
| RI-10 | `feat/ri-10-routing-intelligence-ui` | `feat/ri-09` head | pending | pending | pending | queued |

## Per-PR acceptance log
Expand Down Expand Up @@ -258,6 +258,30 @@ other; closing one is a maintainer decision and neither is stale.
- Base sync deferred: waiting for RI-05 (#1012) to merge before updating
these branches from `dev`.

### RI-09 - feat/ri-09-route-explainability-api

- Base SHA: `410db97e4cd9e9b4f8aba60682d20946e211d6dd` (`dev` after #1015 merge)
- Reviewed commit: `d887c120282c8d381f92cd11c1eebe2373a27281`
- Findings (self-review / full-review): fixed on this head -
(1) dry-run preserves `parseCandidateEvidence(...) === null` as
`400 invalid_candidates`; (2) combo explanations report the physical last
attempt; (3) absent providers leave `encryptedCodexTasks` unknown;
(4) CLI USAGE documents `evaluate` and rejects option-like profile ids;
(5) assembleCandidateEvidence typed as `OcxConfig` after the RI-08 health/
quota/cost merge.
- Final commit: `d887c120282c8d381f92cd11c1eebe2373a27281`
- PR: #1016 https://github.com/lidge-jun/opencodex/pull/1016
- Verification:
- `bun x tsc --noEmit`: PASSED (0 errors)
- `bun run test tests/route-explainability.test.ts`: 10/10 pass -
trace+attempts+outcome merge, 404 unknown ids, pre-trace rows, combo
physical final attempt, dry-run auto-evidence, malformed candidates 400,
absent-provider encryptedCodexTasks unknown, CLI logs explain encode/json,
CLI logs explain missing-id, CLI route policy evaluate dry-run + id guard
- Focused regression suites: cost/quota/routing-profile + explainability green
- `bun run privacy:scan`: passed
- Remaining Low findings: none

## Baseline note

The full-suite baseline on this Windows machine did not complete within the
Expand Down
15 changes: 14 additions & 1 deletion src/cli/observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
const USAGE = `Usage:
ocx observe logs [--provider <name>] [--model <id>] [--status <code>]
[--limit <n>] [--follow] [--json|--jsonl]
ocx logs explain <request-id> [--json]
ocx logs rebuild-index
ocx logs index-status
ocx observe usage [--range <7d|30d|all>] [--surface <all|codex|claude|grok>] [--json]
Expand Down Expand Up @@ -81,6 +82,17 @@ async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
} while (true);
}

async function explain(argv: string[], deps: RuntimeApiDeps): Promise<void> {
const args = [...argv];
const requestId = args.shift();
const wantsJson = takeFlag(args, "--json");
if (!requestId) throw new CliUsageError("request id is required", USAGE);
rejectArgs(args, USAGE);
const encoded = encodeURIComponent(requestId);
const result = await runtimeRequest(`/api/request-history/${encoded}/route-decision`, {}, deps);
printData(result, wantsJson, wantsJson ? undefined : [JSON.stringify(result, null, 2)]);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function rebuildIndex(argv: string[], deps: RuntimeApiDeps): Promise<void> {
const args = [...argv];
const wantsJson = takeFlag(args, "--json");
Expand Down Expand Up @@ -140,7 +152,8 @@ export async function handleObserveCommand(argv: string[], deps: RuntimeApiDeps
const [sub = "logs", ...rest] = argv;
if (sub === "logs") {
const action = rest[0];
if (action === "rebuild-index") await rebuildIndex(rest.slice(1), deps);
if (action === "explain") await explain(rest.slice(1), deps);
else if (action === "rebuild-index") await rebuildIndex(rest.slice(1), deps);
else if (action === "index-status") await indexStatus(rest.slice(1), deps);
else await logs(rest, deps);
}
Expand Down
10 changes: 6 additions & 4 deletions src/cli/route-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ 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]
ocx route policy evaluate <id> [--model-context <tokens>] [--tools]
[--image] [--structured-output] [--json]`;

interface ProfileRow {
Expand Down Expand Up @@ -40,7 +42,7 @@ 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);
if (!id || id.startsWith("-")) 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);
Expand All @@ -52,7 +54,7 @@ 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);
if (!id || id.startsWith("-")) 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");
Expand Down Expand Up @@ -81,10 +83,10 @@ async function dryRun(argv: string[], deps: RuntimeApiDeps): Promise<void> {
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) throw new CliUsageError("route policy requires a subcommand (list, show, dry-run, evaluate)", 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 if (sub === "dry-run" || sub === "evaluate") await dryRun(rest, deps);
else throw new CliUsageError(`unknown route policy command: ${sub}`, USAGE);
});
}
11 changes: 7 additions & 4 deletions src/routing/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,12 @@ export function candidateCapabilityEvidence(
: tierSupport === false ? "unsupported" : "unknown";

const localRemote = localRemoteEvidence(provider?.baseUrl);
const encryptedCodexTasks = isCanonicalOpenAiForwardProvider(
provider ?? { adapter: "", authMode: undefined, baseUrl: undefined },
);
// Only emit a definitive encryptedCodexTasks value when the provider is
// present. An absent/unconfigured provider must stay unknown so
// require.encryptedCodexTasks does not fail closed on missing config.
const encryptedCodexTasks = provider === undefined
? undefined
: isCanonicalOpenAiForwardProvider(provider);

return {
...(typeof contextWindow === "number" ? { contextWindow } : {}),
Expand All @@ -196,6 +199,6 @@ export function candidateCapabilityEvidence(
...(reasoningEfforts !== undefined && reasoningEfforts.length > 0 ? { reasoningEfforts } : {}),
...(serviceTier !== "unknown" ? { serviceTier } : {}),
...localRemote,
encryptedCodexTasks,
...(typeof encryptedCodexTasks === "boolean" ? { encryptedCodexTasks } : {}),
};
}
58 changes: 58 additions & 0 deletions src/server/management/request-history-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
*
* - `GET /api/request-history` - keyset-paginated rows with filters
* - `GET /api/request-history/:requestId` - one canonical row
* - `GET /api/request-history/:requestId/route-decision` - why-this-route
* explanation (RI-09): durable trace + attempts + outcome
*
* The index is a derived projection of `usage.jsonl`; every response carries
* an `index` status block so callers can see schema version, indexed rows and
Expand All @@ -19,6 +21,7 @@ import { requestLogEntryFromPersistedUsage } from "../request-log";
import { requestLogDto } from "./shared";
import { jsonResponse } from "../auth-cors";
import type { ManagementContext } from "./context";
import type { PersistedUsageEntry } from "../../usage/log";

function parseQueryInt(raw: string | null): number | undefined | "invalid" {
if (raw === null) return undefined;
Expand All @@ -28,6 +31,17 @@ function parseQueryInt(raw: string | null): number | undefined | "invalid" {
return Number.isInteger(value) ? value : "invalid";
}

function finalAttemptTarget(entry: PersistedUsageEntry): { provider: string; model: string } {
const attempts = entry.attempts;
if (Array.isArray(attempts) && attempts.length > 0) {
const last = attempts[attempts.length - 1];
if (last && typeof last.provider === "string" && typeof last.model === "string") {
return { provider: last.provider, model: last.model };
}
}
return { provider: entry.provider, model: entry.model };
}

export async function handleRequestHistoryRoutes(ctx: ManagementContext): Promise<Response | null> {
const { url, req, config } = ctx;
if (!url.pathname.startsWith("/api/request-history")) return null;
Expand Down Expand Up @@ -113,6 +127,50 @@ export async function handleRequestHistoryRoutes(ctx: ManagementContext): Promis
}

if (url.pathname.startsWith("/api/request-history/") && req.method === "GET") {
// Why-this-route explanation (RI-09): trace + attempts + outcome.
if (url.pathname.endsWith("/route-decision")) {
let requestId: string;
try {
requestId = decodeURIComponent(
url.pathname.slice("/api/request-history/".length, -"/route-decision".length),
);
} catch {
return jsonResponse({ error: { code: "not_found", message: "unknown request" } }, 404, req, config);
}
if (!requestId || requestId.includes("/")) {
return jsonResponse({ error: { code: "not_found", message: "unknown request" } }, 404, req, config);
}
const entry = await requestHistoryRowById(requestId);
if (!entry) {
return jsonResponse({ error: { code: "not_found", message: "unknown request" } }, 404, req, config);
}
const trace = entry.routeDecision ?? null;
const final = finalAttemptTarget(entry);
return jsonResponse({
requestId,
routeDecision: trace,
attemptSequence: entry.attempts ?? [],
outcome: {
status: entry.status,
...(entry.terminalStatus ? { terminalStatus: entry.terminalStatus } : {}),
...(entry.closeReason ? { closeReason: entry.closeReason } : {}),
...(entry.errorCode ? { errorCode: entry.errorCode } : {}),
...(entry.durationMs !== undefined ? { durationMs: entry.durationMs } : {}),
...(entry.usageStatus ? { usageStatus: entry.usageStatus } : {}),
},
summary: {
requestedModel: entry.requestedModel ?? entry.model,
routeKind: trace?.routeKind ?? null,
...(trace?.profile ? { profileId: trace.profile.id, revision: trace.profile.revision } : {}),
selected: trace?.selected
? { provider: trace.selected.provider, model: trace.selected.model }
: null,
finalProvider: final.provider,
finalModel: final.model,
},
}, 200, req, config);
}

let requestId: string;
try {
requestId = decodeURIComponent(url.pathname.slice("/api/request-history/".length));
Expand Down
77 changes: 46 additions & 31 deletions src/server/management/routing-profile-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { evaluatePolicyProfile, type PolicyCandidateEvidence, type PolicyRequest
import { candidateCapabilityEvidence } from "../../routing/capability";
import { policyCandidateHealthEvidence } from "../../routing/health";
import { quotaEvidenceForCandidate } from "../../routing/quota";
import { costEvidenceForCandidate } from "../../routing/cost";
import { providerCodexAccountMode } from "../../providers/registry";
import { getEffectiveActiveCodexAccountId } from "../../codex/routing";
import { getAccountSet } from "../../oauth/store";
Expand All @@ -19,6 +20,7 @@ import { isPlainRecord } from "./shared";
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
import { jsonResponse } from "../auth-cors";
import type { ManagementContext } from "./context";
import type { OcxConfig } from "../../types";

function profileDto(config: Parameters<typeof getRoutingProfile>[0], id: string): Record<string, unknown> | null {
const profile = getRoutingProfile(config, id);
Expand Down Expand Up @@ -86,6 +88,49 @@ function parseCandidateEvidence(raw: unknown): PolicyCandidateEvidence[] | null
return out;
}

function assembleCandidateEvidence(
config: OcxConfig,
profile: NonNullable<ReturnType<typeof getRoutingProfile>>,
): PolicyCandidateEvidence[] {
// Match execution: fill the same candidate evidence the router would
// assemble, so dry-run/evaluate reports the same eligibility as real routing
// instead of treating every capability as unknown. Cost is always present so
// evaluate mode can surface the profile limit even without a usage estimate.
return profile.candidates.map(candidate => ({
provider: candidate.provider,
model: candidate.model,
capability: candidateCapabilityEvidence(config, candidate.provider, candidate.model),
health: policyCandidateHealthEvidence(config, candidate),
quota: quotaEvidenceForCandidate({
provider: candidate.provider,
model: candidate.model,
...(candidate.provider === OPENAI_CODEX_PROVIDER_ID
&& providerCodexAccountMode(
OPENAI_CODEX_PROVIDER_ID,
config.providers[OPENAI_CODEX_PROVIDER_ID],
) === "pool"
? (() => {
const codexAccountId = getEffectiveActiveCodexAccountId(config);
return {
codexAccountId,
codexAccountPlan: codexAccountId
? config.codexAccounts?.find(account => account.id === codexAccountId)?.plan
: undefined,
};
})()
: {}),
accountRef: candidate.provider === "anthropic"
? getAccountSet("anthropic")?.activeAccountId
: undefined,
}),
cost: costEvidenceForCandidate({
provider: candidate.provider,
model: candidate.model,
limitUsd: profile.limits.maxEstimatedCostUsd,
}),
}));
}

export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promise<Response | null> {
const { req, url, config } = ctx;

Expand Down Expand Up @@ -119,37 +164,7 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis
return jsonResponse({ error: { code: "invalid_evidence", message: "evidence must be an object" } }, 400, req, config);
}
const candidateEvidence = body.candidates === undefined
// Match execution: fill the same candidate evidence the router would
// assemble, so dry-run reports the same eligibility as real routing
// instead of treating every capability as unknown.
? resolvedProfile.candidates.map(candidate => ({
provider: candidate.provider,
model: candidate.model,
capability: candidateCapabilityEvidence(config, candidate.provider, candidate.model),
health: policyCandidateHealthEvidence(config, candidate),
quota: quotaEvidenceForCandidate({
provider: candidate.provider,
model: candidate.model,
...(candidate.provider === OPENAI_CODEX_PROVIDER_ID
&& providerCodexAccountMode(
OPENAI_CODEX_PROVIDER_ID,
config.providers[OPENAI_CODEX_PROVIDER_ID],
) === "pool"
? (() => {
const codexAccountId = getEffectiveActiveCodexAccountId(config);
return {
codexAccountId,
codexAccountPlan: codexAccountId
? config.codexAccounts?.find(account => account.id === codexAccountId)?.plan
: undefined,
};
})()
: {}),
accountRef: candidate.provider === "anthropic"
? getAccountSet("anthropic")?.activeAccountId
: undefined,
}),
}))
? assembleCandidateEvidence(config, resolvedProfile)
: parseCandidateEvidence(body.candidates);
if (candidateEvidence === null) {
return jsonResponse({ error: { code: "invalid_candidates", message: "candidates must be an array of evidence objects" } }, 400, req, config);
Expand Down
Loading
Loading