diff --git a/devlog/_plan/260914_cost_guard_stabilization/000_unit.md b/devlog/_plan/260914_cost_guard_stabilization/000_unit.md index 63d800c88b..87c6cffe36 100644 --- a/devlog/_plan/260914_cost_guard_stabilization/000_unit.md +++ b/devlog/_plan/260914_cost_guard_stabilization/000_unit.md @@ -83,8 +83,10 @@ what protects the operator who explicitly opts back out. ## Write scope Permitted: `src/codex/routing.ts`, `src/types/config.ts`, `src/config.ts`, the -account-pool and session-affinity code, their tests under -`tests/codex-integration/`, `docs-site/` configuration reference and its locales, +account-pool and session-affinity code, `src/routing/` for the identity, quota and +cache-domain layers `090_remaining_stack.md` plans (wpc's classifier, wpe's reservation +ledger, wpf's probe lease), their tests under `tests/codex-integration/` and +`tests/routing/`, `docs-site/` configuration reference and its locales, `structure/` docs that own the affected invariants, and this unit. Excluded, owned by concurrent lanes: `src/providers/devin*`, diff --git a/devlog/_plan/260914_cost_guard_stabilization/090_remaining_stack.md b/devlog/_plan/260914_cost_guard_stabilization/090_remaining_stack.md new file mode 100644 index 0000000000..74308842a0 --- /dev/null +++ b/devlog/_plan/260914_cost_guard_stabilization/090_remaining_stack.md @@ -0,0 +1,93 @@ +# 090 — what is left after 2.55.0, as a seven-layer stack + +## Why this doc exists + +`070_delivery.md` closed the 2.55.0 release record with a list of things the +release deliberately does not claim: the durable cross-restart reservation +ledger, V2 child first placement, the minimum quota/cache domain contract, the +transient half-open probe lease, combo hops on the shared budget, Cursor's inner +retries, and sends-per-logical-request surfacing. That list is accurate and it is +also unordered, which is the problem. Each item touches a different layer of the +same request path, and three of them change the same two files. + +This doc fixes the order and the write scopes so the remaining work can ship as a +stack of independently revertible pull requests rather than one unreviewable diff. + +## What is already true + +Stating this once, because repeating the original problem statement as if nothing +landed is the failure mode this unit keeps hitting. On `dev@4f788f91`: a request +carries a guarded four-send profile with a three-send base allowance and one shared +final-recovery reserve (#4609); a zero budget no longer floors to one (#4613); the +workflow guard caps physical sends, distinct children and concurrency and reserves +an interactive slot (#4614); a healthy detour is promoted rather than discarded when +a hold expires, and `Retry-After` is honoured on the transient path (#4616). + +So the remaining work is not "add a budget". It is: make the budget reach the +paths it still cannot see, make it correct under concurrency, and stop it from +being reset by a restart or side-stepped by a fresh identity. + +## The stack + +Listed in the order the branches are stacked, each one based on the branch above it. + +| # | Layer | Branch | What it closes | +| --- | --- | --- | --- | +| 1 | wpc | `codex/4546-wpc-quota-cache-domains` | Auth identity, quota domain and cache domain as three separate values, plus conversational-state portability as its own check | +| 2 | wpe | `codex/4546-wpe-durable-reservation` | Token-and-output reservation at three scopes, unresolved spend, and a ledger that survives restart | +| 3 | wpf | `codex/4546-wpf-probe-lease-backpressure` | The half-open probe lease, `Retry-After` preserved past the local maximum, and pool-wide retry backpressure | +| 4 | wpd | `codex/4546-wpd-v2-lineage-placement` | V2 root/parent/thread lineage and child first placement onto the parent's current serving account | +| 5 | wpa | `codex/4546-wpa-dispatch-coverage` | The reset-retry counting seam, compact's routed fallback, the generic OAuth and Anthropic hops, the gated-400 ladder's relation to the shared cap, and permit atomicity | +| 6 | wpb | `codex/4546-wpb-combo-adapter-retries` | Combo's real hop and target transition under a per-target policy, and Cursor's and Kiro's inner retries | +| 7 | wpg | `codex/4546-wpg-spend-instrumentation` | Sends per logical request, reserved/settled/unresolved spend, cache provenance, and a no-account failure that explains itself | + +Only two of those adjacencies are real dependencies. wpb needs wpa's permit +contract to be atomic before a second dispatcher may be wired to it, and wpg +reports what every earlier layer produces, so it is last by construction rather +than by importance. The rest are contract layers that introduce a module and its +tests without rewiring a call site, which is what makes them stackable in +readiness order and revertible one at a time. + +That independence is deliberate and it is also the honest limitation of the first +three layers: wpc's classifier, wpe's ledger and wpf's lease are each landed +tested and, for now, partly unreferenced. Each one names in its own pull request +which later layer is obliged to call it. A module that nobody calls does not +protect anything, so the stack is not finished until the wiring layers land on +top of it. + +## The three corrections this stack is built on + +**"Passes the holder" and "limits every send" are different completion +conditions.** #4608 gave a combo child the budget object; #4609 gave the request a +policy. Neither makes a second combo target draw the remainder, because the +adapter's initial send still reads its own policy allowance. A layer that receives +the counter and does not consult it as a limit reintroduces the multiplier +silently. + +**The permit is not yet atomic.** `reserveDispatch()` evaluates the remainder and +`permit.use()` charges it. Two legs that reserve concurrently against one +remaining send both receive a permit. The fix is to make the reservation the +charge and add an explicit release for an abandoned reservation, which is why wpa +has to land before anything else wires a new caller. + +**A memory Map is not a budget.** The ledger lives in process memory, and cleanup +only protects roots with active requests, so an exhausted-but-idle root can be +deleted and recreated fresh under the same id. Until reservations are durable and +cleanup is exhaustion-aware, "this root is out of budget" means "out of budget +until something restarts". + +## Verification posture + +Unchanged from `070_delivery.md` and restated because it governs every layer here: +the local suite, typecheck, install and build are **not run**, by explicit +instruction. Pushes use `--no-verify`. The only proof is hosted CI at the exact +final head SHA of each branch, and a green run against an earlier commit is not +evidence for the head that merges. Each pull request states that posture in its +Verification section rather than implying a local green. + +## What would make this fail + +Landing wpe's refusal path with a default limit low enough to refuse an +unconfigured install. The count caps from #4614 are already live and permissive; +token accounting must start observational and only enforce behind explicit +operator configuration, or the first upgrade turns a cost guard into an outage. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 7eb8a12524..9a0b822f52 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -54,6 +54,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `autoSwitchThreshold?` | `number` | `80` | Usage threshold for placing new/unbound work. `quota` can re-evaluate unbound tasks on their next request once usage crosses this threshold. Bound tasks keep their account past the threshold by default (`pool.cacheAffinity`); they leave only when that account is exhausted or otherwise cannot serve, and then only for an account with genuine quota headroom and strictly lower usage. Set `pool.cacheAffinity: false` to re-evaluate bound tasks at this threshold, still only onto such a destination. `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request. Bound tasks follow `pool.cacheAffinity` (on by default): they stay until the account is exhausted (known usage at 100%) or otherwise cannot serve, and then may rebind only to an account with genuine quota headroom and strictly lower usage. Set the flag `false` to proactively rebind a bound task at the threshold, still only onto such a destination. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. `reset-first`: Prefer the nearest future 5-hour or weekly reset among accounts below the usage threshold. Bound tasks follow the configured affinity policy. Independent model quotas use quota ordering. Monthly resets do not determine this ordering. | | `pool.cacheAffinity?` | `boolean` | `true` | Cache-affinity ordering for bound Codex threads, independent of `pool.kernel`. On by default; omitting the key or setting `true` keeps a bound task on its account until that account genuinely cannot serve. Only an explicit `false` restores threshold-based rebinding of bound tasks. A live binding outranks quota headroom: `quota` does not move the thread merely because usage crossed `autoSwitchThreshold`. The thread still leaves if that account cannot serve — paused, unusable, or genuinely exhausted (known usage at 100%) — and then only to an account with genuine quota headroom and strictly lower usage. Under either setting, an account with unknown usage is never chosen as a destination for a bound task, so when every account sits above the threshold the task stays put. Affinity is a reordering, not a pin. | +| `pool.credentialGroups?` | `Array<{ id: string; credentials: string[]; note?: string }>` | `[]` | Accepted and validated, but not yet consumed by routing: declaring a group changes no routing decision until a consuming layer lands. Operator-declared quota domains: groups of credentials that demonstrably share one upstream usage limit. Members of one group count once toward available capacity, and a quota refusal inside a group is not answered by rotating to another member — the limit is the same, so the move would pay a cold prefix for zero new capacity. Declared groups speak only to quota; sharing a limit says nothing about prompt-cache compatibility, which is classified separately. Each member is written provider-qualified as `":"`, because a credential id means something only inside its provider; the provider segment accepts the usual aliases (`chatgpt:` and `codex:` both mean OpenAI). Group ids must be unique, `credentials` must be non-empty, and a credential may appear in at most one group — an ambiguous declaration is rejected on write and dropped with a warning on load rather than resolved by whichever group is listed first, since that would merge two unrelated quota domains. A malformed list costs only the grouping: `pool.kernel` and `pool.cacheAffinity` are preserved. Absent or empty means no declared grouping, so an unconfigured install behaves exactly as before. | | `accountPoolStickyLimit?` | `number` | `1` | New/unbound task assignments retained on one round-robin selection before advancing; the counter advances when a task is bound, not after an upstream success. Range 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient failures before future new sessions fail over. Set `0` to disable. For regular Responses and native compact sends, proven pre-connection DNS/TCP reachability failures are tracked at the provider-host level: they never affect account health, account cooldowns, thread/session affinity, active-account selection, or Pool routing, and never count toward this threshold. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Opt-in circuit threshold for proven pre-connection DNS/TCP failures on native OpenAI forward Responses and compact sends. `0` disables it; `1`–`20` opens a 30-second provider-origin cooldown after that many terminal logical requests. While open, requests receive `503` with `Retry-After` before account selection or upstream send; after cooldown, one half-open request is admitted. Timeouts and HTTP responses never count, and any HTTP response closes the circuit. Applies only to Codex Pool routing with no pinned account; it is inert for `codexAccountMode: "direct"` and account-qualified selectors. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 81a50f69a2..38fcac80cd 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1208,6 +1208,7 @@ "routing-compatibility-boundaries.test.ts": "routing", "routing-compatibility-model-matching.test.ts": "routing", "routing-compatibility.test.ts": "routing", + "routing-identity-domains.test.ts": "routing", "routing-intelligence-ui.test.ts": "gui", "routing-policy-fallback.test.ts": "routing", "routing-policy-pool-quota.test.ts": "routing", diff --git a/src/config.ts b/src/config.ts index 4f851e4ffb..935fce734f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -60,6 +60,7 @@ import { import { parseAccountPriority } from "./codex/pool-rotation"; import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types"; import { routingProfileIssues } from "./routing/profile"; +import { credentialGroupIssues } from "./routing/identity-domains"; import { POLICY_NAMESPACE } from "./routing/profile-namespace"; import { forgetEphemeralSecretPath, @@ -1201,6 +1202,43 @@ const codexPoolSchema = z.object({ excludedPlans: z.array(z.string().trim().min(1)).optional(), }).strict(); +/** + * Shape guard for the cross-element checks below. Zod runs an array-level check even + * when an element failed its own validation, and a failed element is not the shape the + * checker expects — reading `credentials.length` off it would throw out of `safeParse` + * and take the whole config load with it. Those elements already carry their own issues. + */ +function isCredentialGroupShape(value: unknown): value is { id: string; credentials: string[] } { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const group = value as { id?: unknown; credentials?: unknown }; + return typeof group.id === "string" + && Array.isArray(group.credentials) + && group.credentials.every(member => typeof member === "string"); +} + +/** + * Operator-declared quota domains (`pool.credentialGroups`). + * + * Loose enough to hand-write, strict enough that it cannot mean two things: unique group + * ids, a non-empty member list, provider-qualified members, and each credential in at + * most one group. Those are not tidiness rules. `classifyCredential` keys a declared + * domain by group id, so a duplicate id or a credential listed twice merges two quota + * domains the operator never said were one -- after which the pool counts real capacity + * once and declines to rotate into it. A bare credential id is ambiguous for the same + * reason ids are provider-scoped in the auth store, so members carry their provider. + * {@link credentialGroupIssues} is the single definition, shared with the classifier. + */ +const credentialGroupsSchema = z.array(z.object({ + id: z.string().trim().min(1), + credentials: z.array(z.string().trim().min(1)).min(1), + note: z.string().optional(), +})).superRefine((groups, ctx) => { + if (!Array.isArray(groups) || !groups.every(isCredentialGroupShape)) return; + for (const message of credentialGroupIssues(groups)) { + ctx.addIssue({ code: "custom", message }); + } +}); + /** * Quota-reset notification section. * @@ -1392,6 +1430,12 @@ const configSchema = z.object({ pool: z.object({ kernel: z.boolean().optional(), cacheAffinity: z.boolean().optional(), + // The catch belongs on the list, not on `pool`. Left to the outer catch below, one + // malformed group failed this nested object and dropped the whole `pool` -- taking + // `kernel` and `cacheAffinity` with it, which is a live routing change the operator + // never made. Scoped here, a malformed or ambiguous group costs only the declared + // grouping: loadConfig warns, and the write path rejects it outright. + credentialGroups: credentialGroupsSchema.optional().catch(undefined), }).optional().catch(undefined), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), @@ -2147,6 +2191,30 @@ function warnDegradedCodexQuotaAutoRefresh(rawParsed: unknown, validated: OcxCon if (warning) console.warn(`⚠️ config.json ${warning}`); } +/** + * Companion to the degrade warnings above, for a malformed or ambiguous declared + * grouping. The list now degrades on its own so the rest of `pool` survives, which is + * also why it needs a voice: nothing else about the config looks different afterwards, + * and silently ungrouped credentials read as capacity the pool does not have. + */ +function degradedCredentialGroupsWarning(rawParsed: unknown): string | null { + const pool = rawConfigRecord(rawConfigRecord(rawParsed)?.pool); + if (!pool || pool.credentialGroups === undefined) return null; + const parsed = credentialGroupsSchema.safeParse(pool.credentialGroups); + if (parsed.success) return null; + // Every issue message is redacted before it is joined. The custom messages embed the + // offending member through `JSON.stringify`, so a malformed credential string that + // happens to carry secret material would otherwise be printed verbatim at config load + // — a config file is exactly where a pasted token ends up in the wrong field. + const details = parsed.error.issues.map(issue => redactSecretString(issue.message)).join("; "); + return `pool.credentialGroups is invalid (${details}) — declared quota grouping is disabled; other pool settings were preserved`; +} + +function warnDegradedCredentialGroups(rawParsed: unknown): void { + const warning = degradedCredentialGroupsWarning(rawParsed); + if (warning) console.warn(`⚠️ config.json ${warning}`); +} + /** * The apiKeys schema salvages entry by entry rather than failing the parse, so a * dropped key is otherwise invisible — and it will not be re-saved by the next @@ -2616,6 +2684,7 @@ export function loadConfig(): OcxConfig { warnDegradedQuotaResetNotify(parsed); warnDegradedCatalogAutoRefresh(parsed); warnDegradedCodexPool(parsed); + warnDegradedCredentialGroups(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } // Schema validation failed — merge defaults into the raw object instead of @@ -2659,6 +2728,7 @@ export function loadConfig(): OcxConfig { warnDegradedQuotaResetNotify(parsed); warnDegradedCatalogAutoRefresh(parsed); warnDegradedCodexPool(parsed); + warnDegradedCredentialGroups(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } // Still failing, but if every complaint is about one or more named entries @@ -2687,6 +2757,7 @@ export function loadConfig(): OcxConfig { warnDegradedQuotaResetNotify(parsed); warnDegradedCatalogAutoRefresh(parsed); warnDegradedCodexPool(parsed); + warnDegradedCredentialGroups(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } } @@ -3050,6 +3121,26 @@ function codexAccountPrioritiesError(value: unknown): string | null { return null; } +/** + * Same reasoning as {@link codexAccountPrioritiesError}, plus one of its own. The read + * path drops an invalid grouping, so a degraded write would erase a declaration the + * operator is still editing and still report success. And an ambiguous declaration -- + * one id used twice, one credential in two groups -- has no safe silent answer at all: + * resolving it by list order would quietly merge two quota domains. A live caller is + * told which group is the problem instead. + */ +function poolCredentialGroupsError(value: unknown): string | null { + const pool = rawConfigRecord(rawConfigRecord(value)?.pool); + if (!pool || pool.credentialGroups === undefined) return null; + const parsed = credentialGroupsSchema.safeParse(pool.credentialGroups); + if (parsed.success) return null; + const details = parsed.error.issues.map(issue => { + const path = issue.path.join("."); + return path ? `${path}: ${issue.message}` : issue.message; + }).join("; "); + return `schema_invalid: pool.credentialGroups: ${details}`; +} + function codexQuotaAutoRefreshError(value: unknown): string | null { const raw = rawConfigRecord(value); if (!raw || raw.codexQuotaAutoRefresh === undefined) return null; @@ -3248,6 +3339,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? codexPoolError(value) ?? googleAntigravityStaticCatalogVersionError(value) ?? codexAccountPrioritiesError(value) + ?? poolCredentialGroupsError(value) ?? codexQuotaAutoRefreshError(value) ?? codexAccountPickerEnabledError(value) ?? emptyCompletionRetryError(value) diff --git a/src/routing/identity-domains.ts b/src/routing/identity-domains.ts new file mode 100644 index 0000000000..8d7431b180 --- /dev/null +++ b/src/routing/identity-domains.ts @@ -0,0 +1,449 @@ +/** + * Authentication identity, quota domain, and cache domain are three different + * questions (#4546, wp6). + * + * A credential pool is stored as a flat list, which smuggles in two assumptions that are + * each wrong in the opposite direction: two API keys are treated as two independent pools + * of capacity, and two accounts on one provider are treated as not sharing a cache. The + * first overcounts available capacity -- OpenAI rate limits are per organization and + * project, so failing over from key A to key B inside the same limit buys nothing while + * still paying a cold prefix. The second discards warm prefixes the provider would have + * served, or worse, assumes a hit the provider never promised. + * + * This module is a conservative CLASSIFIER, not a claim about where a provider stores + * anything. Every answer carries provenance: "operator-declared" comes from configured + * credential groups, "provider-documented" comes from the small built-in table below for + * the cases the PRD names, and "unknown" is a first-class result. "unknown" is never + * silently read as "no sharing" and never as "shared" -- relations report it explicitly + * so the caller applies its own conservative rule. + * + * Provenance is only half of it. A documented rule can prove that two credentials are in + * DIFFERENT domains without proving that two others are in the SAME one, so every domain + * also carries which of those two facts its key supports ({@link DomainEvidence}). That + * is why two OpenAI keys in one organization and region relate "unknown" for cache: the + * documentation separates, then declines to promise the hit. + * + * Conversational-state portability is a separate question from cache compatibility and + * is deliberately not folded into the domain keys: a request carrying + * previous_response_id, a provider-side conversation id, uploaded file ids, or encrypted + * reasoning cannot be replayed onto another credential at all, no matter how the domains + * relate. `canPortConversationState` is that separate check. + */ + +/** Where a domain answer comes from. Order of trust: operator > provider docs > nothing. */ +export type IdentityDomainProvenance = "operator-declared" | "provider-documented" | "unknown"; + +/** + * What a domain key is evidence FOR, which is two facts rather than one. + * + * Proven SEPARATION and proven SHARING are different claims, and a provider routinely + * gives the first without the second. OpenAI documents that prompt caches are not shared + * across organizations or processing regions, and in the same breath documents that + * changing keys inside one organization does not guarantee a hit. So a different + * org-or-region key proves two domains, while an identical one proves nothing: a + * positive cache inference needs the provider to actually promise the hit, and here the + * provider declines to. Inferring "shared" from an equal key would be the same guess + * this module exists to refuse, only pointed the other way. + * + * "separates" therefore means two different keys are two different domains while two + * identical keys stay "unknown". "separates-and-shares" means the same source also + * promised that one key is one domain. + */ +export type DomainEvidence = "separates" | "separates-and-shares"; + +/** + * An opaque, comparable domain. `key` is only meaningful for equality when both sides + * are known; two "unknown" domains never compare shared because each carries a key + * derived from its own credential id. `evidence` decides whether an equal key is even + * allowed to mean "shared". + */ +export interface IdentityDomain { + readonly key: string; + readonly provenance: IdentityDomainProvenance; + readonly evidence: DomainEvidence; +} + +/** + * What the classifier knows about one credential. Every field beyond `credentialId` is + * optional evidence; a documented rule that needs a field this ref does not have yields + * "unknown", never a guess. + */ +export interface CredentialDomainRef { + readonly credentialId: string; + readonly provider?: string; + readonly organizationId?: string; + readonly projectId?: string; + readonly workspaceId?: string; + readonly deploymentId?: string; + readonly region?: string; +} + +export interface CredentialIdentity { + /** The credential the request is sent as. Never grouped, never shared. */ + readonly authIdentity: string; + /** The set of credentials that demonstrably share one usage limit. */ + readonly quotaDomain: IdentityDomain; + /** The conservative prompt-cache compatibility class. */ + readonly cacheDomain: IdentityDomain; + /** + * Group ids that claim this credential when the declaration is ambiguous: the same + * group id declared twice, or the credential listed in more than one group. An + * ambiguous declaration is never resolved by list order -- the quota domain falls back + * to the provider-documented or unknown answer and the conflict is reported here. + * `pool.credentialGroups` rejects such a declaration on write and drops it on load, so + * this covers a caller that assembled groups some other way. + */ + readonly declaredGroupConflict?: readonly string[]; +} + +/** + * How two domains relate. "unknown" is returned rather than collapsed into either + * answer, because treating it as "distinct" rotates within a shared limit (paying a + * cold prefix for zero capacity) and treating it as "shared" strands capacity that may + * be independent. An equal key whose evidence only proves separation also relates + * "unknown", which is how a documented non-sharing rule stays a non-sharing rule. + */ +export type DomainRelation = "shared" | "distinct" | "unknown"; + +/** + * Operator-declared grouping from `pool.credentialGroups`. + * + * `credentials` holds PROVIDER-QUALIFIED ids, `":"`. A bare id + * is ambiguous: credential ids are provider-scoped everywhere else -- `src/oauth/store.ts` + * keys an account by provider and id -- so `"acct-1"` names one credential per provider, + * and a bare declaration would silently merge unrelated quota domains. The provider + * segment normalizes through the same alias table as a classified ref, so + * `"chatgpt:acct-1"` and `"codex:acct-1"` name the same credential. + * + * Group ids must be unique, `credentials` must be non-empty, and a credential may appear + * in at most one group. {@link credentialGroupIssues} is the shared checker. + */ +export interface DeclaredCredentialGroup { + readonly id: string; + readonly credentials: readonly string[]; + readonly note?: string; +} + +/** + * The provider-documented cases the PRD names, and only those. A rule returns + * undefined when the ref lacks the evidence the documentation requires; the caller + * then classifies "unknown" rather than extrapolating. + * + * - OpenAI: rate limits are per organization and project, with model groups sharing a + * limit; prompt caches are not shared across organizations or processing regions. + * - Anthropic: prompt cache is isolated per workspace even inside one organization. + * (Cache-read tokens are also excluded from input TPM there, which is quota + * accounting, not domain shape, so it does not appear here.) + * - Azure: limits and cache breakpoints are per deployment. + * + * Each rule also carries what its documented sentence proves ({@link DomainEvidence}), + * because two of these are separation rules and the rest promise sharing as well. + */ +interface DocumentedDomainRule { + key(ref: CredentialDomainRef): string | undefined; + readonly evidence: DomainEvidence; +} + +const PROVIDER_DOCUMENTED_DOMAINS: Record = { + openai: { + quota: { + // Positive on both halves: the limit is defined per organization and project, and + // model groups share one limit, so two keys in one org and project are one limit. + key: (ref) => ref.organizationId !== undefined && ref.projectId !== undefined + ? `openai:org:${ref.organizationId}:project:${ref.projectId}` + : undefined, + evidence: "separates-and-shares", + }, + cache: { + // Separation only. The documentation says caches are not shared across + // organizations or processing regions, and says in the same place that changing + // keys inside one organization does not guarantee a hit. So a different org or + // region is proven distinct, while same org and region is "unknown" -- claiming + // "shared" there would assert a warm prefix the provider explicitly refuses to + // promise, and the caller would pay for it by replaying a long prompt that misses. + key: (ref) => ref.organizationId !== undefined && ref.region !== undefined + ? `openai:org:${ref.organizationId}:region:${ref.region}` + : undefined, + evidence: "separates", + }, + }, + anthropic: { + cache: { + // The cache is scoped to the workspace as a resource: isolated from other + // workspaces inside one organization, and reused within it. Both halves come from + // the same documented scoping, so an equal key may mean shared. + key: (ref) => ref.workspaceId !== undefined + ? `anthropic:workspace:${ref.workspaceId}` + : undefined, + evidence: "separates-and-shares", + }, + }, + azure: { + quota: { + // Quota and cache are both properties of the deployment resource itself. + key: (ref) => ref.deploymentId !== undefined + ? `azure:deployment:${ref.deploymentId}` + : undefined, + evidence: "separates-and-shares", + }, + cache: { + key: (ref) => ref.deploymentId !== undefined + ? `azure:deployment:${ref.deploymentId}` + : undefined, + evidence: "separates-and-shares", + }, + }, +}; + +const PROVIDER_ALIASES: Record = { + "azure-openai": "azure", + "chatgpt": "openai", + "codex": "openai", +}; + +function normalizedProvider(provider: string | undefined): string | undefined { + if (provider === undefined) return undefined; + const lowered = provider.trim().toLowerCase(); + return PROVIDER_ALIASES[lowered] ?? lowered; +} + +function unknownDomain(kind: "quota" | "cache", credentialId: string): IdentityDomain { + // The credential id in the key keeps two unknown domains from ever comparing equal: + // uniqueness is what makes "unknown" impossible to misread as "shared". + return { key: `unknown:${kind}:${credentialId}`, provenance: "unknown", evidence: "separates" }; +} + +function documentedDomain( + rule: DocumentedDomainRule | undefined, + ref: CredentialDomainRef, +): IdentityDomain | undefined { + const key = rule?.key(ref); + if (rule === undefined || key === undefined) return undefined; + return { key, provenance: "provider-documented", evidence: rule.evidence }; +} + +/** `":"`, the only accepted spelling of a declared member. */ +export const CREDENTIAL_GROUP_MEMBER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*:\S+$/; + +function splitMember(member: string): { provider: string; credentialId: string } | undefined { + if (!CREDENTIAL_GROUP_MEMBER_PATTERN.test(member)) return undefined; + const separator = member.indexOf(":"); + const provider = normalizedProvider(member.slice(0, separator)); + if (provider === undefined || provider === "") return undefined; + return { provider, credentialId: member.slice(separator + 1) }; +} + +function canonicalMember(member: string): string { + const parsed = splitMember(member); + return parsed === undefined ? `unqualified:${member}` : `${parsed.provider}:${parsed.credentialId}`; +} + +function memberMatches(member: string, ref: CredentialDomainRef): boolean { + const parsed = splitMember(member); + if (parsed === undefined) return false; + if (parsed.credentialId !== ref.credentialId) return false; + const refProvider = normalizedProvider(ref.provider); + // A ref without a provider cannot be matched to a provider-scoped declaration, so it + // keeps the documented or unknown answer instead of borrowing someone else's group. + return refProvider !== undefined && refProvider === parsed.provider; +} + +/** + * Every way a declared grouping can be ambiguous, as operator-readable messages. The + * config write path rejects on any of these and the load path drops the list, so an + * ambiguous declaration is reported rather than resolved by whichever group came first. + */ +export function credentialGroupIssues(groups: readonly DeclaredCredentialGroup[]): string[] { + const issues: string[] = []; + const seenIds = new Set(); + const owner = new Map(); + for (const group of groups) { + // A duplicate id is not cosmetic: both groups key to `declared:`, so the second + // group's members join the first group's quota domain without anyone saying so. + if (seenIds.has(group.id)) issues.push(`duplicate group id ${JSON.stringify(group.id)}`); + seenIds.add(group.id); + if (group.credentials.length === 0) { + issues.push(`group ${JSON.stringify(group.id)} lists no credentials`); + } + for (const member of group.credentials) { + if (splitMember(member) === undefined) { + issues.push( + `group ${JSON.stringify(group.id)} member ${JSON.stringify(member)} must be provider-qualified as ":"`, + ); + continue; + } + const existing = owner.get(canonicalMember(member)); + if (existing === group.id) { + issues.push(`credential ${JSON.stringify(member)} is listed twice in group ${JSON.stringify(group.id)}`); + } else if (existing !== undefined) { + issues.push( + `credential ${JSON.stringify(member)} is declared in more than one group (${existing}, ${group.id})`, + ); + } else { + owner.set(canonicalMember(member), group.id); + } + } + } + return issues; +} + +function resolveDeclaredGroup( + ref: CredentialDomainRef, + groups: readonly DeclaredCredentialGroup[], +): { group?: DeclaredCredentialGroup; conflict?: readonly string[] } { + const matches = groups.filter((group) => group.credentials.some((member) => memberMatches(member, ref))); + if (matches.length === 0) return {}; + const conflicting = new Set(); + for (const match of matches) { + if (matches.length > 1) conflicting.add(match.id); + if (groups.filter((group) => group.id === match.id).length > 1) conflicting.add(match.id); + } + if (conflicting.size > 0) return { conflict: [...conflicting] }; + return { group: matches[0] }; +} + +/** + * Classify one credential. `declaredGroups` is `pool.credentialGroups`; an operator + * declaration wins over the provider table because the operator can observe account + * topology the table cannot. Declared groups speak only to quota: sharing a usage + * limit says nothing about cache compatibility, so the cache domain never reads them. + * + * An ambiguous declaration -- a duplicated group id, or a credential claimed by two + * groups -- is not resolved by taking the first match. It is reported on + * `declaredGroupConflict` and the quota domain falls back to the documented or unknown + * answer, so a config that slipped past validation cannot silently merge two unrelated + * quota domains. + */ +export function classifyCredential( + ref: CredentialDomainRef, + declaredGroups: readonly DeclaredCredentialGroup[] = [], +): CredentialIdentity { + const { group: declared, conflict } = resolveDeclaredGroup(ref, declaredGroups); + const documented = PROVIDER_DOCUMENTED_DOMAINS[normalizedProvider(ref.provider) ?? ""] ?? {}; + + const quotaDomain: IdentityDomain = declared !== undefined + ? { key: `declared:${declared.id}`, provenance: "operator-declared", evidence: "separates-and-shares" } + : documentedDomain(documented.quota, ref) ?? unknownDomain("quota", ref.credentialId); + + const cacheDomain: IdentityDomain = documentedDomain(documented.cache, ref) + ?? unknownDomain("cache", ref.credentialId); + + return conflict === undefined + ? { authIdentity: ref.credentialId, quotaDomain, cacheDomain } + : { authIdentity: ref.credentialId, quotaDomain, cacheDomain, declaredGroupConflict: conflict }; +} + +function relateDomains(a: IdentityDomain, b: IdentityDomain): DomainRelation { + if (a.provenance === "unknown" || b.provenance === "unknown") return "unknown"; + if (a.key !== b.key) return "distinct"; + // Equal keys are proof of sharing only when both sides' evidence includes the sharing + // half. A separation-only rule (OpenAI's cache) stops here at "unknown". + return a.evidence === "separates-and-shares" && b.evidence === "separates-and-shares" + ? "shared" + : "unknown"; +} + +export function relateQuotaDomain(a: CredentialIdentity, b: CredentialIdentity): DomainRelation { + return relateDomains(a.quotaDomain, b.quotaDomain); +} + +export function relateCacheDomain(a: CredentialIdentity, b: CredentialIdentity): DomainRelation { + return relateDomains(a.cacheDomain, b.cacheDomain); +} + +/** + * What a quota refusal on `from` means for rotating to `to`. A refusal inside a known + * shared domain must not be answered by rotating within it -- the limit is the same, + * so the move pays a cold prefix for zero new capacity. "unknown" hands the decision + * back to the caller, which applies its own conservative rule. + */ +export type QuotaRotationVerdict = "same-domain" | "distinct-domain" | "unknown"; + +export function assessQuotaRotation( + from: CredentialIdentity, + to: CredentialIdentity, +): QuotaRotationVerdict { + const relation = relateQuotaDomain(from, to); + if (relation === "shared") return "same-domain"; + if (relation === "distinct") return "distinct-domain"; + return "unknown"; +} + +/** + * Available capacity across a credential set. Credentials in one known quota domain + * count ONCE. Unknown-domain credentials are reported separately rather than merged + * into either count, so the caller decides whether each is its own pool or not. + */ +export function countQuotaCapacity(identities: readonly CredentialIdentity[]): { + readonly known: number; + readonly unknown: number; +} { + const knownKeys = new Set(); + let unknown = 0; + for (const identity of identities) { + if (identity.quotaDomain.provenance === "unknown") { + unknown += 1; + } else { + knownKeys.add(identity.quotaDomain.key); + } + } + return { known: knownKeys.size, unknown }; +} + +/** Why a conversation cannot be replayed onto a different credential. */ +export type PortabilityDenial = + | "previous-response-id" + | "provider-conversation-id" + | "uploaded-file-ids" + | "encrypted-reasoning"; + +/** + * The parts of a request that bind it to the credential that produced them. Presence + * is what matters; the values stay opaque so nothing here logs or inspects ids. + */ +export interface ConversationStateCarriers { + readonly previousResponseId?: string | null; + readonly providerConversationId?: string | null; + readonly fileIds?: readonly string[]; + readonly encryptedReasoning?: unknown; +} + +export type PortabilityVerdict = + | { readonly portable: true } + | { readonly portable: false; readonly reason: PortabilityDenial }; + +function present(value: unknown): boolean { + if (value === undefined || value === null) return false; + if (typeof value === "string" || Array.isArray(value)) return value.length > 0; + return true; +} + +/** + * Whether a request's conversational state can move credentials at all. This is NOT + * cache compatibility: a shared cacheDomain means a replayed prefix might hit, while a + * refusal here means replaying is wrong regardless of warmth -- a previous_response_id + * or provider conversation id names server-side state another credential cannot see, + * and an uploaded file id or encrypted reasoning payload is bound to the account that + * issued it. A same-cacheDomain answer must never be read as portability, and a + * portable request gains no cache promise. + */ +export function canPortConversationState( + state: ConversationStateCarriers, +): PortabilityVerdict { + if (present(state.previousResponseId)) { + return { portable: false, reason: "previous-response-id" }; + } + if (present(state.providerConversationId)) { + return { portable: false, reason: "provider-conversation-id" }; + } + if (present(state.fileIds)) { + return { portable: false, reason: "uploaded-file-ids" }; + } + if (present(state.encryptedReasoning)) { + return { portable: false, reason: "encrypted-reasoning" }; + } + return { portable: true }; +} diff --git a/src/types/config.ts b/src/types/config.ts index 4d985d64f5..bcc21c825d 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -882,6 +882,36 @@ export interface OcxConfig { * binding under either setting -- neither is a cache-affinity preference. */ cacheAffinity?: boolean; + /** + * Operator-declared quota domains: groups of credential ids that demonstrably share + * one upstream usage limit (#4546, wp6). Members of one group count once toward + * available capacity, and a quota refusal inside a group is never answered by + * rotating to another member -- the limit is the same, so the move would pay a cold + * prefix for zero new capacity. + * + * Declared groups speak only to quota. Sharing a usage limit says nothing about + * prompt-cache compatibility, which keeps its own provider-documented domain. + * Absent or empty means no declared grouping, so an unconfigured install behaves + * exactly as before. + * + * A declaration has to mean exactly one thing, so the config rejects the spellings + * that could mean two. Credential ids are provider-scoped elsewhere (the auth store + * keys an account by provider and id), so each member is written + * `":"` -- a bare `"acct-1"` names one credential per + * provider and would merge unrelated domains. The provider segment is matched + * case-insensitively through the usual aliases, so `chatgpt:` and `codex:` both mean + * OpenAI. Group ids must be unique, `credentials` must be non-empty, and a credential + * may belong to at most one group; a declaration that breaks any of those is rejected + * on write and dropped with a warning on load, never resolved by list order. + */ + credentialGroups?: Array<{ + /** Operator-chosen group identifier; only equality matters. */ + id: string; + /** Provider-qualified credential ids (`":"`), non-empty. */ + credentials: string[]; + /** Free-text provenance note for the operator's own records. */ + note?: string; + }>; }; /** Active pool account id for next session. undefined = main (passthrough as-is). */ activeCodexAccountId?: string; diff --git a/structure/catalog.md b/structure/catalog.md index 47a827426b..9e97a330b6 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -234,6 +234,37 @@ Pool mode routes across main plus added Codex credentials. Key rules: generation it started from still holds; a lost race raises a generation-conflict error rather than overwriting the newer credential (`src/codex/account-store.ts`). Callers handle that error; they do not assume a silent retry. +- **Authentication identity, quota domain, and cache domain are tracked separately** + (`src/routing/identity-domains.ts`). `classifyCredential` returns all three with provenance: + `pool.credentialGroups` supplies operator-declared quota domains, a small built-in table + supplies the provider-documented cases (OpenAI limits per organization and project and caches + per organization and region, Anthropic cache per workspace, Azure per deployment), and every + other answer is `unknown`. `unknown` is a first-class relation result, never silently read as + shared or as distinct: `assessQuotaRotation` reports `same-domain` so a quota refusal is not + answered by rotating inside the limit that refused, `countQuotaCapacity` counts one known + domain once and reports unknown-domain credentials separately, and + `canPortConversationState` keeps conversational-state portability a separate question from + cache compatibility by refusing any request that carries `previous_response_id`, a + provider-side conversation id, uploaded file ids, or encrypted reasoning. The classifier is groundwork that no routing boundary calls yet: it lands with its tests + so the consuming layers can be reviewed one at a time. Until one of them wires it, declaring + `pool.credentialGroups` changes no routing decision, and the rules above state the contract + those consumers must honour rather than behaviour an operator can rely on today. +- **Proven separation and proven sharing are separate facts** (`src/routing/identity-domains.ts`). + Every domain carries `evidence` alongside its provenance: a rule that documents only that two + credentials are in different domains never lets an equal key mean "shared". OpenAI's cache rule + is the case that forces it — caches are documented as not shared across organizations or + processing regions, while changing keys inside one organization is documented as not + guaranteeing a hit, so a different org or region relates `distinct` and the same org and region + relates `unknown`. OpenAI quota, Anthropic workspace cache, and Azure deployment domains carry + the sharing half as well and still relate `shared`. +- **A declared credential group cannot mean two things** (`src/routing/identity-domains.ts`, + `src/config.ts`). `credentialGroupIssues` is the one definition of a valid grouping: unique + group ids, a non-empty member list, and each credential in at most one group, with members + written `":"` because ids are provider-scoped in the auth store. The + config write path rejects a declaration that breaks any of those and the load path drops the + list with a warning, keeping `pool.kernel` and `pool.cacheAffinity`; `classifyCredential` + reports an ambiguous claim on `declaredGroupConflict` and falls back to the documented or + unknown answer rather than taking the first matching group. Warmup issues a bounded request with a fallback model so a cold account reports usability before a real turn depends on it (`src/codex/warmup.ts`). diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index 9e98c709ea..62dd4b24fe 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -339,3 +339,63 @@ test("an invalid desktopProfile is dropped without resetting providers (#4430)", expect(error.mock.calls.join("\n")).not.toContain("Using default config"); } finally { error.mockRestore(); } }); + +function writePoolConfig(credentialGroups: unknown): string { + const bytes = JSON.stringify({ + ...candidate(undefined), + pool: { kernel: true, cacheAffinity: false, credentialGroups }, + }); + writeFileSync(getConfigPath(), bytes); + return bytes; +} + +test("a malformed credentialGroups entry costs the list, not the rest of pool (#4546)", () => { + const bytes = writePoolConfig([ + { id: "team", credentials: ["openai:key-a"] }, + { id: "", credentials: [] }, + ]); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const loaded = loadConfig(); + expect(loaded.pool?.credentialGroups).toBeUndefined(); + // The two siblings are live routing settings: an outer catch used to take them both + // because one group failed the nested object. + expect(loaded.pool?.kernel).toBe(true); + expect(loaded.pool?.cacheAffinity).toBe(false); + expect(loaded.providers.xai.note).toBe("keep me"); + expect(warn.mock.calls.flat().join("\n")).toContain("pool.credentialGroups"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + } finally { warn.mockRestore(); } +}); + +test("an ambiguous credentialGroups declaration is rejected on write, never ordered away (#4546)", () => { + const base = candidate(undefined); + const withGroups = (credentialGroups: unknown) => ({ ...base, pool: { kernel: true, credentialGroups } }); + + const twoGroups = validateConfigCandidate(withGroups([ + { id: "left", credentials: ["openai:key-a"] }, + { id: "right", credentials: ["openai:key-a"] }, + ])); + expect(twoGroups.ok).toBe(false); + expect(twoGroups.ok === false && twoGroups.error).toContain("pool.credentialGroups"); + + const duplicateId = validateConfigCandidate(withGroups([ + { id: "team", credentials: ["openai:key-a"] }, + { id: "team", credentials: ["openai:key-b"] }, + ])); + expect(duplicateId.ok).toBe(false); + expect(duplicateId.ok === false && duplicateId.error).toContain("duplicate group id"); + + const bareId = validateConfigCandidate(withGroups([{ id: "team", credentials: ["key-a"] }])); + expect(bareId.ok).toBe(false); + expect(bareId.ok === false && bareId.error).toContain("provider-qualified"); + + const empty = validateConfigCandidate(withGroups([{ id: "team", credentials: [] }])); + expect(empty.ok).toBe(false); + + const valid = validateConfigCandidate(withGroups([ + { id: "team", credentials: ["openai:key-a", "azure:key-a"], note: "one billed org" }, + ])); + expect(valid.ok).toBe(true); + expect(valid.ok === true && valid.config.pool?.credentialGroups).toHaveLength(1); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0888b0825f..2fcc00f1ed 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1036,6 +1036,7 @@ "routing-compatibility-boundaries.test.ts": "routing", "routing-compatibility-model-matching.test.ts": "routing", "routing-compatibility.test.ts": "routing", + "routing-identity-domains.test.ts": "routing", "routing-intelligence-ui.test.ts": "gui", "routing-policy-fallback.test.ts": "routing", "routing-policy-pool-quota.test.ts": "routing", diff --git a/tests/routing/routing-identity-domains.test.ts b/tests/routing/routing-identity-domains.test.ts new file mode 100644 index 0000000000..fa30a83db4 --- /dev/null +++ b/tests/routing/routing-identity-domains.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, test } from "bun:test"; + +import { + assessQuotaRotation, + canPortConversationState, + classifyCredential, + countQuotaCapacity, + credentialGroupIssues, + CREDENTIAL_GROUP_MEMBER_PATTERN, + relateCacheDomain, + relateQuotaDomain, + type CredentialIdentity, + type DeclaredCredentialGroup, +} from "../../src/routing/identity-domains"; + +function identity( + credentialId: string, + ref: Partial[0]> = {}, + groups: readonly DeclaredCredentialGroup[] = [], +): CredentialIdentity { + return classifyCredential({ credentialId, ...ref }, groups); +} + +const OPENAI_ORG_PROJECT = { provider: "openai", organizationId: "org-1", projectId: "proj-1" }; + +describe("credential identity domains", () => { + test("authIdentity is always the credential itself, never grouped", () => { + const a = identity("key-a", OPENAI_ORG_PROJECT); + const b = identity("key-b", OPENAI_ORG_PROJECT); + expect(a.authIdentity).toBe("key-a"); + expect(b.authIdentity).toBe("key-b"); + expect(a.authIdentity).not.toBe(b.authIdentity); + }); + + test("operator-declared groups win over the provider table for quota", () => { + const groups: DeclaredCredentialGroup[] = [ + { id: "team", credentials: ["openai:key-a", "openai:key-b"], note: "same billed org" }, + ]; + const a = identity("key-a", { provider: "openai", organizationId: "org-1", projectId: "p-1" }, groups); + const b = identity("key-b", { provider: "openai", organizationId: "org-9", projectId: "p-9" }, groups); + expect(a.quotaDomain.provenance).toBe("operator-declared"); + expect(relateQuotaDomain(a, b)).toBe("shared"); + }); + + test("a declared quota group says nothing about cache compatibility", () => { + const groups: DeclaredCredentialGroup[] = [ + { id: "team", credentials: ["openai:key-a", "openai:key-b"] }, + ]; + const a = identity("key-a", { provider: "openai" }, groups); + const b = identity("key-b", { provider: "openai" }, groups); + expect(relateQuotaDomain(a, b)).toBe("shared"); + expect(relateCacheDomain(a, b)).toBe("unknown"); + }); + + test("OpenAI quota domain is per organization and project", () => { + const a = identity("key-a", OPENAI_ORG_PROJECT); + const b = identity("key-b", OPENAI_ORG_PROJECT); + const otherProject = identity("key-c", { ...OPENAI_ORG_PROJECT, projectId: "proj-2" }); + expect(a.quotaDomain.provenance).toBe("provider-documented"); + expect(relateQuotaDomain(a, b)).toBe("shared"); + expect(relateQuotaDomain(a, otherProject)).toBe("distinct"); + }); + + test("a documented rule missing its evidence yields unknown, not a guess", () => { + const orgOnly = identity("key-a", { provider: "openai", organizationId: "org-1" }); + const same = identity("key-b", { provider: "openai", organizationId: "org-1" }); + expect(orgOnly.quotaDomain.provenance).toBe("unknown"); + expect(relateQuotaDomain(orgOnly, same)).toBe("unknown"); + }); + + test("OpenAI proves cache SEPARATION without proving cache sharing", () => { + const sameOrgRegion = { provider: "openai", organizationId: "org-1", region: "us" }; + const a = identity("key-a", sameOrgRegion); + const b = identity("key-b", sameOrgRegion); + const otherRegion = identity("key-c", { ...sameOrgRegion, region: "eu" }); + const otherOrg = identity("key-d", { ...sameOrgRegion, organizationId: "org-2" }); + // A different organization or region is documented as a different cache. + expect(relateCacheDomain(a, otherRegion)).toBe("distinct"); + expect(relateCacheDomain(a, otherOrg)).toBe("distinct"); + // The same organization and region is NOT documented as one cache: changing keys + // inside an organization is explicitly not guaranteed to hit, so the equal key is + // separation evidence only and the relation stays unknown. + expect(a.cacheDomain.key).toBe(b.cacheDomain.key); + expect(a.cacheDomain.provenance).toBe("provider-documented"); + expect(a.cacheDomain.evidence).toBe("separates"); + expect(relateCacheDomain(a, b)).toBe("unknown"); + // The quota rule for the same provider does promise sharing, and is unaffected. + const quotaA = identity("key-a", { ...sameOrgRegion, projectId: "p-1" }); + const quotaB = identity("key-b", { ...sameOrgRegion, projectId: "p-1" }); + expect(relateQuotaDomain(quotaA, quotaB)).toBe("shared"); + }); + + test("unknown is never read as shared and never as distinct", () => { + const a = identity("key-a", { provider: "obscure" }); + const b = identity("key-b", { provider: "obscure" }); + expect(relateQuotaDomain(a, b)).toBe("unknown"); + expect(relateCacheDomain(a, b)).toBe("unknown"); + expect(a.quotaDomain.key).not.toBe(b.quotaDomain.key); + }); + + test("Anthropic isolates prompt cache per workspace", () => { + const a = identity("k1", { provider: "anthropic", workspaceId: "ws-1" }); + const b = identity("k2", { provider: "anthropic", workspaceId: "ws-1" }); + const other = identity("k3", { provider: "anthropic", workspaceId: "ws-2" }); + expect(relateCacheDomain(a, b)).toBe("shared"); + expect(relateCacheDomain(a, other)).toBe("distinct"); + // Anthropic quota sharing is not one of the documented cases. + expect(relateQuotaDomain(a, b)).toBe("unknown"); + }); + + test("Azure domains are per deployment", () => { + const a = identity("d1", { provider: "azure", deploymentId: "dep-1" }); + const b = identity("d2", { provider: "azure", deploymentId: "dep-1" }); + const other = identity("d3", { provider: "azure", deploymentId: "dep-2" }); + expect(relateQuotaDomain(a, b)).toBe("shared"); + expect(relateCacheDomain(a, b)).toBe("shared"); + expect(relateQuotaDomain(a, other)).toBe("distinct"); + }); +}); + +describe("declared groups are unambiguous or they do not apply", () => { + test("a bare credential id never matches: membership is provider-scoped", () => { + const groups: DeclaredCredentialGroup[] = [{ id: "team", credentials: ["key-a"] }]; + const a = identity("key-a", { provider: "openai", organizationId: "org-1", projectId: "p-1" }, groups); + expect(a.quotaDomain.provenance).toBe("provider-documented"); + expect(credentialGroupIssues(groups)).toHaveLength(1); + expect(credentialGroupIssues(groups)[0]).toContain("provider-qualified"); + expect(CREDENTIAL_GROUP_MEMBER_PATTERN.test("key-a")).toBe(false); + expect(CREDENTIAL_GROUP_MEMBER_PATTERN.test("openai:key-a")).toBe(true); + }); + + test("the provider segment normalizes through the same aliases as a ref", () => { + const groups: DeclaredCredentialGroup[] = [{ id: "team", credentials: ["chatgpt:key-a"] }]; + const a = identity("key-a", { provider: "codex" }, groups); + expect(a.quotaDomain.provenance).toBe("operator-declared"); + expect(credentialGroupIssues(groups)).toEqual([]); + }); + + test("a credential claimed by two groups is reported, not resolved by order", () => { + const groups: DeclaredCredentialGroup[] = [ + { id: "left", credentials: ["openai:key-a"] }, + { id: "right", credentials: ["openai:key-a"] }, + ]; + const a = identity("key-a", { provider: "openai", organizationId: "org-1", projectId: "p-1" }, groups); + expect(a.declaredGroupConflict).toEqual(["left", "right"]); + // Falls back to the documented answer rather than joining whichever group came first. + expect(a.quotaDomain.provenance).toBe("provider-documented"); + expect(credentialGroupIssues(groups).join("; ")).toContain("more than one group"); + }); + + test("a duplicated group id is a conflict, because both groups key the same domain", () => { + const groups: DeclaredCredentialGroup[] = [ + { id: "team", credentials: ["openai:key-a"] }, + { id: "team", credentials: ["openai:key-b"] }, + ]; + const a = identity("key-a", { provider: "openai" }, groups); + const b = identity("key-b", { provider: "openai" }, groups); + expect(a.declaredGroupConflict).toEqual(["team"]); + expect(b.declaredGroupConflict).toEqual(["team"]); + expect(relateQuotaDomain(a, b)).toBe("unknown"); + expect(credentialGroupIssues(groups).join("; ")).toContain("duplicate group id"); + }); + + test("an empty member list and a repeated member are reported", () => { + expect(credentialGroupIssues([{ id: "team", credentials: [] }]).join("; ")) + .toContain("lists no credentials"); + expect(credentialGroupIssues([{ id: "team", credentials: ["openai:key-a", "openai:key-a"] }]).join("; ")) + .toContain("listed twice"); + }); + + test("an unambiguous declaration still applies", () => { + const groups: DeclaredCredentialGroup[] = [ + { id: "left", credentials: ["openai:key-a"] }, + { id: "right", credentials: ["azure:key-a"] }, + ]; + const openai = identity("key-a", { provider: "openai" }, groups); + const azure = identity("key-a", { provider: "azure", deploymentId: "dep-1" }, groups); + expect(openai.declaredGroupConflict).toBeUndefined(); + expect(openai.quotaDomain.key).toBe("declared:left"); + expect(azure.quotaDomain.key).toBe("declared:right"); + expect(relateQuotaDomain(openai, azure)).toBe("distinct"); + expect(credentialGroupIssues(groups)).toEqual([]); + }); +}); + +describe("quota refusal rotation", () => { + test("a refusal inside a known shared domain must not rotate within it", () => { + const a = identity("key-a", OPENAI_ORG_PROJECT); + const b = identity("key-b", OPENAI_ORG_PROJECT); + expect(assessQuotaRotation(a, b)).toBe("same-domain"); + }); + + test("a refusal may rotate to a credential in a distinct domain", () => { + const a = identity("key-a", OPENAI_ORG_PROJECT); + const b = identity("key-b", { provider: "azure", deploymentId: "dep-1" }); + expect(assessQuotaRotation(a, b)).toBe("distinct-domain"); + }); + + test("unknown domains hand the decision back to the caller", () => { + const a = identity("key-a", { provider: "obscure" }); + const b = identity("key-b", OPENAI_ORG_PROJECT); + expect(assessQuotaRotation(a, b)).toBe("unknown"); + expect(assessQuotaRotation(b, a)).toBe("unknown"); + }); +}); + +describe("quota capacity accounting", () => { + test("two credentials in one quota domain count once", () => { + const a = identity("key-a", OPENAI_ORG_PROJECT); + const b = identity("key-b", OPENAI_ORG_PROJECT); + const c = identity("key-c", { provider: "azure", deploymentId: "dep-1" }); + expect(countQuotaCapacity([a, b, c])).toEqual({ known: 2, unknown: 0 }); + }); + + test("unknown-domain credentials are reported separately, not merged", () => { + const a = identity("key-a", OPENAI_ORG_PROJECT); + const u1 = identity("u1", { provider: "obscure" }); + const u2 = identity("u2", { provider: "obscure" }); + expect(countQuotaCapacity([a, u1, u2])).toEqual({ known: 1, unknown: 2 }); + }); +}); + +describe("conversational-state portability", () => { + test("a state-free request is portable", () => { + expect(canPortConversationState({})).toEqual({ portable: true }); + expect(canPortConversationState({ + previousResponseId: null, + fileIds: [], + encryptedReasoning: undefined, + })).toEqual({ portable: true }); + }); + + test("previous_response_id refuses with a typed reason", () => { + expect(canPortConversationState({ previousResponseId: "resp_1" })).toEqual({ + portable: false, + reason: "previous-response-id", + }); + }); + + test("provider-side conversation id refuses", () => { + expect(canPortConversationState({ providerConversationId: "conv_1" })).toEqual({ + portable: false, + reason: "provider-conversation-id", + }); + }); + + test("uploaded file ids refuse", () => { + expect(canPortConversationState({ fileIds: ["file-1"] })).toEqual({ + portable: false, + reason: "uploaded-file-ids", + }); + }); + + test("encrypted reasoning payloads refuse", () => { + expect(canPortConversationState({ encryptedReasoning: ["blob"] })).toEqual({ + portable: false, + reason: "encrypted-reasoning", + }); + expect(canPortConversationState({ encryptedReasoning: "blob" })).toEqual({ + portable: false, + reason: "encrypted-reasoning", + }); + }); + + test("a shared cache domain is not portability, and portability is not a cache promise", () => { + const a = identity("k1", { provider: "anthropic", workspaceId: "ws-1" }); + const b = identity("k2", { provider: "anthropic", workspaceId: "ws-1" }); + expect(relateCacheDomain(a, b)).toBe("shared"); + // Same cache domain, still not portable once the request carries bound state. + expect(canPortConversationState({ previousResponseId: "resp_1" }).portable).toBe(false); + // Portable state, still no cache promise on an undocumented provider. + const u1 = identity("u1", { provider: "obscure" }); + const u2 = identity("u2", { provider: "obscure" }); + expect(canPortConversationState({}).portable).toBe(true); + expect(relateCacheDomain(u1, u2)).toBe("unknown"); + }); +});