diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 862dd9867c..d51e152adc 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -153,15 +153,17 @@ CLI: `ocx route policy list [--json]`, `ocx route policy show [--json]`, an Dry-run evaluates candidates without sending any upstream request. Quota evidence (`optimize.quota`, `require.minQuotaHeadroom`, `unknownEvidence.quota`) comes from -the local Codex pool and Anthropic account quota caches, which are keyed by account. In **Pool** mode -the canonical `openai` provider preserves its existing account selection, then reads quota for the -selected account; **Direct** mode reads quota only from the current (caller/main) account. For other -providers (e.g. Anthropic), runtime candidates use the provider's active account. Quota evidence -never changes account selection, session affinity, cooldowns, or switching behavior — it only feeds -policy scoring. To see quota-aware behavior in a dry-run, supply account refs through the dry-run/API -candidate evidence: `candidates[].codexAccountId` (Codex pool, provider `openai`) or -`candidates[].accountRef` (Anthropic) derives the matching cached account quota; an explicit -`candidates[].quota` object is echoed as given. +account-keyed Codex and Anthropic quota caches. A runtime candidate receives cached quota only when +the evidence already identifies the account. Unbound canonical `openai` and Anthropic candidates +remain unknown during policy evaluation because Pool selection, Direct caller identity, provider +rotation, and thread affinity are resolved after the policy chooses a provider/model; a process-active +account is not used as a substitute. +Quota evidence never changes account selection, session affinity, cooldowns, or switching behavior — +it only feeds policy scoring. To see quota-aware behavior in an API dry-run, supply account refs in +the candidate evidence sent to `POST /api/routing-profiles/dry-run`: +`candidates[].codexAccountId` (Codex pool, provider `openai`) or `candidates[].accountRef` +(Anthropic) derives the matching cached account quota; an explicit `candidates[].quota` object is +echoed as given. The CLI dry-run cannot supply these per-candidate account fields. ### Combos vs policy profiles diff --git a/src/router.ts b/src/router.ts index 599ec9bb94..ec1d5bb914 100644 --- a/src/router.ts +++ b/src/router.ts @@ -22,8 +22,6 @@ import { import { decodeRoutedModelId, encodeRoutedModelId } from "./providers/slug-codec"; import { getStaleCached } from "./codex/model-cache"; import { codexAccountNamespaceEntries } from "./codex/account-namespaces"; -import { getEffectiveActiveCodexAccountId } from "./codex/routing"; -import { getAccountSet } from "./oauth/store"; import { buildRouteDecisionTrace, type RouteDecisionKind, @@ -513,24 +511,6 @@ function routeModelInternal( 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, diff --git a/src/server/management/routing-profile-routes.ts b/src/server/management/routing-profile-routes.ts index 36687c01b3..08406b003a 100644 --- a/src/server/management/routing-profile-routes.ts +++ b/src/server/management/routing-profile-routes.ts @@ -20,10 +20,6 @@ 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"; -import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { saveConfigPreservingClaudeCode } from "../../config"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { isPlainRecord } from "./shared"; @@ -115,24 +111,6 @@ function assembleCandidateEvidence( 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, diff --git a/tests/quota-scoring.test.ts b/tests/quota-scoring.test.ts index 30a637d84e..7eae3e474c 100644 --- a/tests/quota-scoring.test.ts +++ b/tests/quota-scoring.test.ts @@ -7,6 +7,7 @@ import { setCachedProviderAccountQuotaForTests, clearAccountQuotaCache } from ". import { quotaEvidenceForCandidate, quotaScore } from "../src/routing/quota"; import { evaluatePolicyProfile, QUOTA_UNKNOWN_PENALTY_SCORE } from "../src/routing/evaluator"; import { routeModel } from "../src/router"; +import { getAccountSet, saveCredential } from "../src/oauth/store"; import { closeRequestHistoryIndex } from "../src/routing/history/indexer"; import type { OcxConfig } from "../src/types"; @@ -195,7 +196,7 @@ describe("quota-aware scoring (RI-07)", () => { expect(penalized.candidates[0]!.score!.components.quota).toBe(QUOTA_UNKNOWN_PENALTY_SCORE); }); - test("execution path passes the active codex account into quota evidence", async () => { + test("execution path does not invent Codex quota evidence from the active pool account", async () => { updateAccountQuota("pool-a", 30, 1_800_000_000_000, 20, 1_900_000_000_000); const cfg = config({ codexAccounts: [{ id: "pool-a", email: "pool-a@example.test", isMain: false }], @@ -205,8 +206,41 @@ describe("quota-aware scoring (RI-07)", () => { }, }); const route = routeModel(cfg, "policy/quotaRoute"); - expect(route.routeDecision!.candidates[0]!.quota?.known).toBe(true); - expect(route.routeDecision!.candidates[0]!.quota?.headroom).toBeCloseTo(0.7, 2); + expect(route.routeDecision!.candidates[0]!.accountRef).toBeUndefined(); + expect(route.routeDecision!.candidates[0]!.quota?.known).toBe(false); + expect(route.routeDecision!.candidates[0]!.quota?.headroom).toBeUndefined(); + }); + + test("execution path does not invent Anthropic quota evidence from the active account", async () => { + await saveCredential("anthropic", { + access: "access-a", + refresh: "refresh-a", + expires: Date.now() + 3_600_000, + accountId: "uuid-a", + email: "a@example.test", + }); + const activeId = getAccountSet("anthropic")!.activeAccountId; + setCachedProviderAccountQuotaForTests("anthropic", activeId, { + fiveHourPercent: 40, + updatedAt: Date.now(), + }); + const cfg = config({ + providers: { + anthropic: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "oauth", + models: ["claude-sonnet-5"], + }, + }, + routingProfiles: { + quotaRoute: { candidates: [{ provider: "anthropic", model: "claude-sonnet-5" }] }, + }, + }); + const route = routeModel(cfg, "policy/quotaRoute"); + expect(route.routeDecision!.candidates[0]!.accountRef).toBeUndefined(); + expect(route.routeDecision!.candidates[0]!.quota?.known).toBe(false); + expect(route.routeDecision!.candidates[0]!.quota?.headroom).toBeUndefined(); }); test("exact account selectors and pool strategies remain authoritative", () => { diff --git a/tests/routing-profile.test.ts b/tests/routing-profile.test.ts index 53aacdb8e2..99dc892ddf 100644 --- a/tests/routing-profile.test.ts +++ b/tests/routing-profile.test.ts @@ -476,4 +476,79 @@ describe("routing profiles (RI-04)", () => { expect(body.candidates?.[0]?.quota?.known).toBe(true); expect(body.candidates?.[0]?.quota?.headroom).toBeCloseTo(0.7, 2); }); + + test("API dry-run leaves an unbound Codex candidate quota unknown despite an active pool account", async () => { + updateAccountQuota("pool-a", 30, 1_800_000_000_000, 20, 1_900_000_000_000); + const config = baseConfig({ + providers: { + openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" }, + }, + codexAccounts: [{ id: "pool-a", email: "pool-a@example.test", isMain: false }], + activeCodexAccountId: "pool-a", + routingProfiles: { + only: { candidates: [{ provider: "openai", model: "gpt-5.6" }] }, + }, + }); + const req = new ManagementRequest("http://localhost/api/routing-profiles/dry-run", { + method: "POST", + headers: { "content-type": "application/json" }, + // No candidates[] override: the candidate is unbound, so the dry-run must + // not reach for the process-global active pool account. Policy evaluation + // runs before Pool/Direct identity and thread affinity resolve, so an + // account attached here can differ from the one that executes. + body: JSON.stringify({ profile: "only", evidence: {} }), + }); + const response = await handleManagementAPI(req, new URL(req.url), config, { refreshCodexCatalog: async () => {} }); + expect(response).not.toBeNull(); + expect(response!.status).toBe(200); + const body = await response!.json() as { + candidates?: Array<{ accountRef?: string; quota?: { known?: boolean; headroom?: number } }>; + }; + expect(body.candidates?.[0]?.accountRef).toBeUndefined(); + expect(body.candidates?.[0]?.quota?.known).toBe(false); + expect(body.candidates?.[0]?.quota?.headroom).toBeUndefined(); + }); + + test("API dry-run leaves an unbound Anthropic candidate quota unknown despite an active account", async () => { + const { saveCredential, getAccountSet } = await import("../src/oauth/store"); + const { setCachedProviderAccountQuotaForTests } = await import("../src/providers/quota"); + await saveCredential("anthropic", { + access: "access-a", + refresh: "refresh-a", + expires: Date.now() + 3_600_000, + accountId: "uuid-a", + email: "a@example.test", + }); + const activeId = getAccountSet("anthropic")!.activeAccountId; + setCachedProviderAccountQuotaForTests("anthropic", activeId, { + fiveHourPercent: 40, + updatedAt: Date.now(), + }); + const config = baseConfig({ + providers: { + anthropic: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "oauth", + models: ["claude-sonnet-5"], + }, + }, + routingProfiles: { + only: { candidates: [{ provider: "anthropic", model: "claude-sonnet-5" }] }, + }, + }); + const req = new ManagementRequest("http://localhost/api/routing-profiles/dry-run", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ profile: "only", evidence: {} }), + }); + const response = await handleManagementAPI(req, new URL(req.url), config, { refreshCodexCatalog: async () => {} }); + expect(response).not.toBeNull(); + expect(response!.status).toBe(200); + const body = await response!.json() as { + candidates?: Array<{ accountRef?: string; quota?: { known?: boolean; headroom?: number } }>; + }; + expect(body.candidates?.[0]?.accountRef).toBeUndefined(); + expect(body.candidates?.[0]?.quota?.known).toBe(false); + }); });