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
5 changes: 5 additions & 0 deletions src/server/management/request-history-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ export async function handleRequestHistoryRoutes(ctx: ManagementContext): Promis
return jsonResponse({
requestId,
routeDecision: trace,
// The account decision belongs in the why-this-route answer: a rebound with its cause is
// the difference between "the pool moved this conversation" and "this is a new session".
affinity: entry.affinity
? { move: entry.affinity, reason: entry.affinityReason ?? null }
: null,
Comment on lines +156 to +158

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the new affinity response block

This changes the user-visible JSON returned by both the management endpoint and ocx logs explain, but the operator references still describe the explanation as only trace, attempts, and outcome, while the documented CLI JSON shape omits affinity. Update the English reference, its translated counterparts, and the CLI JSON-shape reference so operators can interpret move, reason, and the legacy null case.

AGENTS.md reference: AGENTS.md:L380-L381

Useful? React with 👍 / 👎.

attemptSequence: entry.attempts ?? [],
outcome: {
status: entry.status,
Expand Down
37 changes: 37 additions & 0 deletions src/usage/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createHash, type Hash } from "node:crypto";
import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, appendFileSync } from "node:fs";
import { join } from "node:path";
import { getConfigDir } from "../config";
import type { CodexAffinityMove, CodexAffinityReason } from "../codex/routing";
import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
import { recordOwnedConfigPath } from "../lib/config-ownership";
import { sanitizeLogMetadataString } from "../lib/redact";
Expand Down Expand Up @@ -187,6 +188,13 @@ export interface PersistedUsageEntry {
transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse";
/** Whether the terminal came from upstream or a proxy-generated tail. */
terminalSource?: "upstream" | "synthetic";
/**
* What happened to this request's Codex pool binding, and why (#4546). A move discards the
* prompt-cache prefix warmed on the previous account, so it is recorded as an event rather
* than left to be inferred from account labels across rows. Additive; older rows omit it.
*/
affinity?: CodexAffinityMove;
affinityReason?: CodexAffinityReason;
Comment on lines +196 to +197

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist affinity fields through the usage-log pipeline

For every request carrying an affinity decision, addFinalRequestLog adds these fields to the in-memory entry, but addRequestLog rebuilds the object passed to appendUsageEntry without them and normalizeUsageEntry also omits them. Because requestHistoryRowById reads and normalizes the persisted usage.jsonl row, this endpoint always receives undefined and returns affinity: null; even the new appendUsageEntry regression loses the values before indexing. Thread both closed-enum fields through the persistence projection and normalization.

Useful? React with 👍 / 👎.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* Bounded route-decision trace (RI-01): why this provider/model/account was
* selected. Additive field; old rows without it parse unchanged. Never
Expand Down Expand Up @@ -248,6 +256,28 @@ export function isKnownTerminalSource(value: unknown): value is NonNullable<Pers
return typeof value === "string" && KNOWN_TERMINAL_SOURCES.has(value as NonNullable<PersistedUsageEntry["terminalSource"]>);
}

/**
* The persisted entry is built by an explicit whitelist, so a field the writer sets but this
* normalizer does not name is dropped without a word. #4592 added the affinity record at the
* call site and it never reached disk for exactly that reason.
*/
const KNOWN_AFFINITY_MOVES = new Set<NonNullable<PersistedUsageEntry["affinity"]>>([
"reused", "held", "detour", "rebound", "new_bind", "cleared",
]);
const KNOWN_AFFINITY_REASONS = new Set<NonNullable<PersistedUsageEntry["affinityReason"]>>([
"healthy", "quota_headroom", "quota_refusal", "transient", "transient_hold_expired",
"unusable", "paused", "plan_excluded", "cooldown", "quota_avoided", "generation",
"expired", "model_lane",
]);

export function isKnownAffinityMove(value: unknown): value is NonNullable<PersistedUsageEntry["affinity"]> {
return typeof value === "string" && KNOWN_AFFINITY_MOVES.has(value as NonNullable<PersistedUsageEntry["affinity"]>);
}

export function isKnownAffinityReason(value: unknown): value is NonNullable<PersistedUsageEntry["affinityReason"]> {
return typeof value === "string" && KNOWN_AFFINITY_REASONS.has(value as NonNullable<PersistedUsageEntry["affinityReason"]>);
}

export function usageLogPath(configDir?: string): string {
return join(configDir ?? getConfigDir(), "usage.jsonl");
}
Expand Down Expand Up @@ -590,6 +620,11 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility);
const transportPhase = isKnownTransportPhase(entry.transportPhase) ? entry.transportPhase : undefined;
const terminalSource = isKnownTerminalSource(entry.terminalSource) ? entry.terminalSource : undefined;
const affinity = isKnownAffinityMove(entry.affinity) ? entry.affinity : undefined;
// A reason without a move describes nothing, so it is only kept alongside one.
const affinityReason = affinity !== undefined && isKnownAffinityReason(entry.affinityReason)
? entry.affinityReason
: undefined;
const routeDecision = entry.routeDecision
? normalizeRouteDecisionTrace(entry.routeDecision)
: undefined;
Expand Down Expand Up @@ -660,6 +695,8 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
...(Array.isArray(entry.attempts) ? { attempts } : {}),
...(transportPhase ? { transportPhase } : {}),
...(terminalSource ? { terminalSource } : {}),
...(affinity ? { affinity } : {}),
...(affinityReason ? { affinityReason } : {}),
...(entry.errorCode ? { errorCode: entry.errorCode } : {}),
...(entry.terminalStatus ? { terminalStatus: entry.terminalStatus } : {}),
...(entry.closeReason ? { closeReason: entry.closeReason } : {}),
Expand Down
21 changes: 21 additions & 0 deletions tests/cli/route-explainability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,27 @@ describe("route explainability (RI-09)", () => {
expect(response.status).toBe(404);
});

test("the account decision and its cause are part of the route explanation (#4546)", async () => {
// An operator asking why a request is on this account should not have to compare account
// labels across rows, which is how the original incident had to be diagnosed.
appendUsageEntry({
...tracedEntry("explain-affinity"),
affinity: "rebound",
affinityReason: "quota_refusal",
});
const response = await apiGet("/api/request-history/explain-affinity/route-decision", config());
expect(response.status).toBe(200);
const body = await response.json() as { affinity?: { move?: string; reason?: string | null } };
expect(body.affinity).toEqual({ move: "rebound", reason: "quota_refusal" });
});

test("a row with no account decision explains with a null affinity block", async () => {
appendUsageEntry(tracedEntry("explain-no-affinity"));
const response = await apiGet("/api/request-history/explain-no-affinity/route-decision", config());
const body = await response.json() as { affinity?: unknown };
expect(body.affinity).toBeNull();
});

test("pre-trace rows explain with null routeDecision and their attempts", async () => {
appendUsageEntry({
requestId: "legacy-row",
Expand Down
Loading