From 00b244e7a6e1b613910de2b99c92cbf461b0e87b Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 11:13:01 +0900 Subject: [PATCH 01/10] docs: plan provider usage and account quota parity stack --- .../000_plan.md | 61 ++ .../001_attribution_evidence.md | 25 + .../002_audit_synthesis.md | 18 + .../010_attribution.md | 224 +++++++ .../020_account_quota_api.md | 626 ++++++++++++++++++ .../030_quota_views.md | 81 +++ .../040_stack_landing.md | 22 + 7 files changed, 1057 insertions(+) create mode 100644 devlog/_plan/260905_provider_usage_quota_parity/000_plan.md create mode 100644 devlog/_plan/260905_provider_usage_quota_parity/001_attribution_evidence.md create mode 100644 devlog/_plan/260905_provider_usage_quota_parity/002_audit_synthesis.md create mode 100644 devlog/_plan/260905_provider_usage_quota_parity/010_attribution.md create mode 100644 devlog/_plan/260905_provider_usage_quota_parity/020_account_quota_api.md create mode 100644 devlog/_plan/260905_provider_usage_quota_parity/030_quota_views.md create mode 100644 devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/000_plan.md b/devlog/_plan/260905_provider_usage_quota_parity/000_plan.md new file mode 100644 index 0000000000..c94a13f9ab --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/000_plan.md @@ -0,0 +1,61 @@ +# Provider usage and account quota parity + +## Loop contract + +- Archetype: spec-satisfaction repair; class C3, management contract changes receive C4 review. +- Trigger: provider detail shows foreign-looking selectors and inconsistent account quotas. +- Goal: truthful serving-provider/model accounting and consistent current/all-account quota views. +- Non-goals: rewriting user history, guessing the actual historical model, broad router strictness for custom aggregators, new providers, authentication changes, deployment or release. +- Verifier: existing remote CI for regression tests; local static type checks, GUI build/lint, privacy scan and isolated browser/API smoke. **No local tests or suites**, including git hooks. Commit/push with `--no-verify`. +- Stop: audited slices, screenshots and exact-head CI, then bottom-up admin merges and fetched-dev ancestry. +- Memory: this unit and the session-bound goalplan/ledger. +- Outcomes: DONE with evidence; external BLOCKED, authority UNSAFE/NEEDS_HUMAN, or stated-bound BUDGET_EXHAUSTED only. +- Delegation: read-only inventory/audit, bounded disjoint implementation only by P amendment; no model/effort overrides. Main reclaims after two distinct failed dispatches. +- Scope/resources: current managed checkout only; existing GitHub PR/CI credentials; sanitized read-only runtime evidence; no inference or reset-credit spending; no new paid services. 90 minutes active work per phase, 6 hours total; no explicit token budget requested. + +## Dependency map + +1. `roadmap`: docs-only cycle; lock all decade plans. +2. `attribution` / `010_attribution.md`: routing and accounting foundation, regression coverage; bottom stack branch `codex/provider-usage-attribution` targets `dev`. +3. `quota-api` / `020_account_quota_api.md`: credential-scoped readers and capability DTOs; `codex/provider-account-quota-api` targets the bottom branch. +4. `quota` / `030_quota_views.md`: current-account and all-account presentation consumes the account API; `codex/provider-quota-parity` targets the API branch. +5. `landing` / `040_stack_landing.md`: independently audit final stack and land bottom-up after exact-head CI. + +## Existing ownership + +```text +src/router.ts route resolution +src/usage/{log,summary,cost}.ts append-only rows and aggregation +src/providers/quota*.ts quota capability and reports +gui/src/provider-workspace/ pure report adapters +gui/src/components/provider-workspace/ provider tabs +structure/05_gui-and-management-api.md current contract +tests/{routing,usage,providers}/ existing regression domains +gui/tests/ rendered component contracts +``` + +Reuse the existing report adapters, QuotaBars and account panels. Doing nothing keeps misleading model labels; deleting rows loses real usage; configuration alone cannot clarify historical rows. No new quota client or history database is justified. + +## Baseline and verification constraints + +Base HEAD `526d4bf64` matched fetched `origin/dev`; initial tree clean. +`bun run typecheck` could not run before dependencies existed (TS2688); after frozen-lock install with scripts disabled the bundled Bun wrapper requires its postinstall. `node node_modules/typescript/bin/tsc --noEmit` exited 0 and reads repository `tsconfig.json`. Use that equivalent direct checker without enabling lifecycle scripts. Remote CI owns test execution, explicitly overriding the local-suite recommendations in repo/skills. + +## Design read + +Keep the supplied developer-dashboard layout, existing CSS tokens, font and icon set. Variance 2, motion 1, density D8. No visual concept generation: this is a utility dashboard repair, not a redesign. Current quota must sit below usage statistics; provider-wide capacity and current-account quota are different concepts. Unknown is not zero, observed is not freshly probed, and an unsupported API is not a failed account. + +## Continuity + +Roadmap locked after Kant's independent audit and two repair rounds, final VERDICT: PASS. +Docs-only delivery: 000, 001, 002 and all four decade docs; no production changes. +Static source checker and GUI build passed; existing chunk-size warning only. No local tests. +Next work-phase: attribution. Open risk: unseen historical upstream model identity cannot be +recovered; we qualify only saved fallback provenance instead of guessing or rewriting rows. + +## Delegation write map (locked before Build) + +- Attribution cycle: Harvey owns `src/usage`, `src/router.ts`, the identified Chat/Messages error catches, `src/server/management/shared.ts`, and corresponding existing backend regression files. Main owns the provider model annotation/share/caching UI, its GUI regressions, docs, commits and CI. No overlapping writes. +- Quota API cycle: Euclid owns quota readers/key-cache/types and relevant provider regression files. Main owns management route joins, API-route regressions and docs; clarify exact exported signatures before either writes. +- Quota UI cycle: main owns report projection/current section; a bounded worker may own account hook/panel refresh and regression changes only after an explicit P amendment gives disjoint paths. +- Independent Kant audits plans read-only; fresh independent final implementation review remains required. No worker changes FSM, goals, branches, commits or remote state, and no worker runs local tests. diff --git a/devlog/_plan/260905_provider_usage_quota_parity/001_attribution_evidence.md b/devlog/_plan/260905_provider_usage_quota_parity/001_attribution_evidence.md new file mode 100644 index 0000000000..fbb98d87cc --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/001_attribution_evidence.md @@ -0,0 +1,25 @@ +# Attribution investigation + +## Competing hypotheses and falsifiers + +- H1: UI groups rows using the selected provider rather than each row's provider. Falsifier: shell groups by `m.provider` and original ledger has those same provider/model pairs. +- H2: persistence keeps a stale provider after a real cross-provider route. Falsifier: persisted route decision and physical attempts both select Kimi via `default-provider`. +- H3: unmatched request selectors fall through to the default provider and remain displayed/priced as if they were confirmed served model identities. Falsifier: router rejects unknown policy selectors, or source/ledger records a distinct resolved physical model. + +## Observed facts + +`ProviderWorkspaceShell.tsx:214-220` groups API model rows by `m.provider`; it does not copy global rows into every provider. `src/router.ts:603-609` explicitly documents missing `policy/` falling through; `:794-798` forwards the unmatched identifier unchanged to the default provider. `src/server/responses/core.ts:2069-2072` writes the actual chosen route provider/model into log context. + +A read-only streaming scan of the real ledger, emitting no credentials, request bodies or account identities, found foreign-looking selectors under Kimi with `routeKind=default-provider` and Kimi physical attempts. The historical nonexistent-policy row also has a successful terminal response and measured tokens. This rejects H1 and the simple stale-provider form of H2. It proves the fallback mechanism in H3, not the identity of the actual model behind the remote endpoint. An echoed `response.model` alone is not such proof. + +Historical usage must stay attributed to the recorded serving provider, not transferred to whichever provider name appears before a slash. Unknown native slash IDs must remain supported for genuine aggregators. Missing reserved policy names can be rejected without globally banning unknown model IDs. + +`src/usage/cost.ts:317-329` falls back to model-level vendor price regardless of serving provider; `vendorPrefixedCost` validates the prefix against the vendor but does not prove that a default-fallback request actually ran that model. A fallback selector needs an honest distinction from confirmed model identity, not a fabricated Kimi price. + +## Unresolved before audit + +Choose a compact backward-compatible fallback presentation which preserves counts, tokens and requested identifiers without mispricing them. Inventory quota capability owners before finalizing 020. No user ledger mutation is authorized or planned. + +## Deterministic local diagnostic (not a test suite) + +Direct `routeModel` invocation with an in-memory config containing only Kimi and model `k3`, no credential or network execution: `policy/does-not-exist`, empty `policy/`, and `anthropic/claude-opus-5` all returned provider `kimi` with routeKind `default-provider`; `k3` returned `explicit-provider`. Process exited 0. This independently reproduces the saved route trace and establishes a negative-case before-state for the later HTTP/CI regressions. diff --git a/devlog/_plan/260905_provider_usage_quota_parity/002_audit_synthesis.md b/devlog/_plan/260905_provider_usage_quota_parity/002_audit_synthesis.md new file mode 100644 index 0000000000..bc5f0f98be --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/002_audit_synthesis.md @@ -0,0 +1,18 @@ +# Roadmap audit synthesis + +Reviewer Kant, first partial round: FAIL (one High, two Medium); 020 not yet reviewed. + +1. Accepted missing attempt DTO cost propagation. Root cause: Logs computes parent and attempt metrics separately. 010 now forwards parent routeDecision into each attempt projection and adds DTO regression coverage. +2. Accepted refresh waiter ownership gap. Root cause: global unversioned success settles newer callers. 030 now binds waiters and callback to captured epoch, supersedes older tickets with false, and tests the actual page coordinator. +3. Accepted enrichment HTTP failure state gap. Root cause: cheap rows overwrite last-good quota, while asynchronous failure is swallowed. 030 now merges by surviving credential ID and uses generation-fenced pending/unavailable transitions. +4. Scope clarification accepted: all unchanged default-provider selectors receive the provenance annotation; only slash-containing unresolved selectors restrict vendor-only pricing. Bare prices remain unchanged. This covers the screenshot's bare Gemini/Qwen selectors without a catalog/history guess. + +No production code changed during this round. Re-audit required. + +Second round: first three blockers closed; two further blockers accepted. Internal key +results now keep a private `isCurrent` closure carrying captured identity/clear epoch through +the final safe DTO projection, with same-ID env/keychain replacement regression. 020's GUI +signature now uses the same boolean refresh argument as 030 (force and await together). +Corrected the Logs projection owner name to `requestLogDto`. Scope-lock also removes the +unnecessary global scheduler/forced-successor design; bounded per-roster workers retain the +required capability without changing global report scheduling. Re-audit only these deltas. diff --git a/devlog/_plan/260905_provider_usage_quota_parity/010_attribution.md b/devlog/_plan/260905_provider_usage_quota_parity/010_attribution.md new file mode 100644 index 0000000000..77e2828f70 --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/010_attribution.md @@ -0,0 +1,224 @@ +# 010 — Requested fallback model attribution + +## Status and scope + +Proposed implementation plan, not implemented or tested. Parent owns orchestration, +implementation, UI coordination, and verification. This delegated task writes only +this document. No local tests, inference, credentials, or user ledger reads. +Source anchors refer to the inspected checkout and must be refreshed if it moves. + +This is an accounting/selection-contract repair, not a model allowlist or provider +capability claim. Keep all historical provider identities, tokens, requests, +attempts, timestamps, and selectors. No ledger rewrite, new history database, +guessed actual model, quota changes, or broad restriction on native slash IDs. + +## Decision + +Annotate all proven unchanged default-provider selectors (including bare gemini/qwen) as +unresolved requested models. Do not move usage to the provider named by a prefix. +Leave routing of ordinary native slash IDs and bare fallback models unchanged. +Reject missing reserved policy selectors prospectively. + +Route trace alone proves fallback provenance, not whether a remote aggregator +recognizes that selector. Therefore "unresolved" means not resolved by this +router to a known model identity; it does NOT mean invalid upstream or unsupported. +Never use today's config/catalog membership to reconstruct historical routing. +No annotation also does NOT mean actual-model confirmation. Existing resolvedModel +is insufficient: src/server/responses/compact.ts:552-558 fills it from routing +before upstream execution; src/server/responses/core.ts:2101 does likewise for +virtual wire identities. + +## Diff 1 — One read-time identity classifier + +Add a small pure owner, src/usage/model-identity.ts, reused by summary and Logs cost +projection. Existing ownership searched: usageModelIdentity (summary.ts:339), +computeEntryCost (:207), estimateAttemptCost (cost.ts:528), estimateRequestCost +(:610), and persisted routeDecision (log.ts:152). No equivalent classifier exists +in the inspected usage module. Keep Antigravity canonicalization in its existing +owner; classify the original row BEFORE display canonicalization. + +Proposed isUnresolvedRequestedModel(source, target) predicate: + +1. source.routeDecision exists, routeKind is default-provider, selected.reason is + default-provider, and truncated.strings is not true. +2. selected.model is nonempty. A separate slash predicate controls pricing restriction. +3. trace.requestedModel === selected.model: the router forwarded it unchanged. +4. target.model === selected.model and the recorded target provider matches the + recorded selected provider (exact, or existing baseProviderLabel normalization + for account-log labels; no config lookup). + +For parent-only rows target is the entry; with attempts it is EACH physical +attempt independently. Do not apply a parent decision to a different attempt. +Do not use source.requestedModel in place of trace.requestedModel: preprocessing +may have rewritten the route selector. Do not derive confirmation from an echoed +resolvedModel. Missing, truncated, or nonmatching trace remains unclassified. +This deliberately fixes proven cases without inventing missing historical facts. + +Examples: unchanged default-provider anthropic-looking, cursor-looking, and +historical policy selectors qualify by structure/evidence, not a vendor list. +Bare kimi-k2.5, claude-opus-5, and other unslashed fallbacks qualify for the provenance marker but retain their existing price eligibility. +Configured explicit-provider native slash IDs and policy/combo target selections +do not qualify. A genuine native slash ID forwarded through default-provider CAN +qualify: that is honest unresolved routing provenance, not grounds to reject it. + +Relevant route facts: src/router.ts:676-687 allows native slashes to fall through; +:794 forwards unchanged; :800-821 records requested/selected identity. +Trace bounds and immutable selection semantics: src/routing/trace.ts:5-14. + +## Diff 2 — Compatible summary annotation, no regrouping loss + +Use additive hasUnresolvedRequestedModel?: true on UsageAttribution, +UsageModelAccumulator, UsageModel, and UsageDayModel in src/usage/summary.ts. +Meaning: this row includes at least one proven unresolved requested-model +attribution. Do not call the opposite state confirmed. + +Full propagation chain: + +- :322, :339-383 — attribution type and parent/attempt identity construction; + preserve model/resolvedModel/raw data, add only the derived marker. +- :59 and :73 — daily and overall public model-row types. +- :516, :673 — accumulator type and initialization. +- :1132-1146 — OR the marker on every matching addition, not only first creation. +- :698 and :706 — clone preserves it; merge ORs it regardless of firstSeen. +- :830 — overflow may OR it with the same "includes" semantics. +- :867 and :889 — emit it in BOTH daily and overall model rows. + +Keep usageModelKey (:359), provider attribution, model IDs, filter matching +(:797-825), totals, and attempt/request deduplication unchanged. No decorated +display text becomes a model key. Mixed rows say "Includes unresolved requested +model usage"; the marker does not imply every token in the row is unresolved. +Exact splits/counts by provenance are unnecessary for this patch. + +The attribution layer also modifies `gui/src/components/provider-workspace/types.ts`, +`ProviderWorkspaceShell.tsx` and `ProviderUsage.tsx`: carry the optional field from +the API row to `ProviderModelUsageRow` and show a translated inline annotation +"Includes unresolved requested model usage" below the original selector. Use the +same annotation in the model table rather than silently dropping historical rows. +All locale modules receive the corresponding key. Keep grouping by m.provider; +recompute `shareRatio` from selected-provider token total, not the global summary, +so the screenshot's nearly empty bars become meaningful within each provider. +Test propagation and selected-provider shares with existing provider workspace +tests. Increment the provider usage session-cache version to prevent stale derived +rows after update. Backend marker alone is not UI completion. + +## Diff 3 — Restrict only unsupported cross-provider price inference + +Do NOT unprice every default-provider route and do NOT globally remove vendor +fallback. Bare fallback models retain their existing pricing exactly. + +In src/usage/cost.ts add an optional final price-resolution options parameter, +allowModelLevelFallback?: boolean (default true), propagated through +resolveMatchedPrice (:176), inner/exact resolution (:221/:243), and any +Antigravity secondary lookup. When false, preserve all exact provider/selector +sources: user overlay, verified override, provider metadata bundle, expected +overlay. Only replace the call to resolveModelLevelPrice at :283 with null. + +Include this mode in priceMemo's key (:203-209), or bypass memoization for the +restricted mode. Normal and restricted calls must never reuse each other's +cached results. Preserve existing provider-label and user-overlay precedence. +Provider-specific rate evidence is a pricing contract, not model confirmation. + +Add optional allowModelLevelFallback to estimateRequestCost's input (:610) and +the transient attempt estimate input type (:528/:569); do not add it to persisted +usage types. estimateComboCost forwards each transient attempt's option through +estimateAttemptCost. Derive false only for predicate-positive attributions whose selector contains a slash. + +In summary.ts:207 computeEntryCost, annotate shallow transient attempt copies +with this option and pass it for parent-only requests. Never mutate attempts or +overwrite model with display text. Preserve partial priced-attempt summary sums; +null estimates remain unpriced, not zero-token/zero-dollar measurements. + +In src/server/management/shared.ts:96 extend MetricSource with optional +routeDecision. In costResult (:132-136) apply the SAME classifier to parent or +each attempt before calling estimators. Otherwise Logs retains the wrong price +after Summary is fixed. Preserve estimateComboCost's all-attempts-or-null behavior +and existing unavailable reasons (:113-128); do not import summary.ts into this +consumer. Also update `requestLogDto` in shared.ts:165: its +individual attempt `costResult` call must carry the parent routeDecision while +matching each attempt's own provider/model. Add parent/attempt/Summary agreement +cases to `tests/server/management-api-logs-metrics.test.ts`. +This is a required implementation scope extension beyond the initial +delegated server-error-only investigation, now explicitly identified for parent. + +Legitimate aggregator behavior: explicit provider native slash routes retain all +pricing; default-provider native slash routes still execute, and retain exact +provider/user rates. Only a vendor-only inferred price becomes unavailable when +the trace proves unresolved slash fallback. Trace alone cannot safely exempt a +genuine aggregator's uncatalogued default-fallback ID while rejecting the same +shape under Kimi. An exemption would require a separate explicit provider/model +contract, not a prefix/catalog guess; no such framework is added in this patch. + +## Diff 4 — Missing policy fails with existing wire error shape + +In src/router.ts near NoEligiblePolicyCandidateError (:49), add exported +UnknownRoutingPolicyError extends Error with readonly profileId and distinct +name/message "Unknown routing policy: ". Do not subclass +NoEligiblePolicyCandidateError or fabricate an evaluation trace. + +At routeModelInternal:603-612, before any provider/default resolution and only +when !bypassCombos, detect the explicit reserved policy/ prefix independently of +resolvePolicyProfileId. If no profile resolves, throw the new error. This must +also reject policy/ with empty suffix (parsePolicyModelId at profile.ts:111-115 +currently returns null). A resolved alias with an unavailable profile also fails. +Preserve valid profile evaluation, alias matching, and concrete recursion bypass. +Do not widen this into a ban on unknown provider prefixes or arbitrary slash IDs. + +Wire contract is 404 with error.type=invalid_request_error: + +- Responses core.ts:2964-2973 and reroute catches :3074/:3198 already map ordinary + routing errors to that response; no new status/envelope is necessary. +- Compact compact.ts:529-536 already has the same generic mapping. +- Chat chat-completions.ts:148-155 and Messages claude-messages.ts:765-771 must + explicitly catch UnknownRoutingPolicyError and return their existing protocol + error helpers, with existing final-request-log handling. Do not fall through + preprocessing to a second route attempt. Only NoEligiblePolicyCandidateError + carries err.trace; keep the two branches/types distinct. + +Historical successful missing-policy entries remain unchanged and annotated on +read. Rejection applies only to new requests. + +## Regression ledger (parent executes remotely; none run here) + +Extend existing files, avoiding new test-file registration churn: + +- tests/usage/usage-summary.test.ts: synthetic trace fixtures for the supplied + Kimi slash shapes and historical policy shape; parent-only and attempts; exact + request/token/provider preservation; daily/overall marker; mixed rows in either + order; partition merge, overflow and filtered/cache parity. Legitimate bare + fallback keeps price. Missing/truncated/nonmatching trace is not inferred. +- tests/usage/usage-cost.test.ts: restricted mode suppresses vendor-only pricing; + exact user/provider rates remain; alternate restricted/normal calls to catch + memo pollution; native aggregator slashes and bare vendor fallback retain normal + behavior. Existing kiro case :170 and aggregator cases :1330 must stay valid. +- tests/usage/usage-aggregate-cache.test.ts: append/rebuild projections agree on + marker and price without touching ledger records. +- tests/usage/usage-surfaces.test.ts (inspect existing helpers first): Logs cost + and Summary agree on eligibility, while retaining their documented partial-sum + versus whole-combo unavailable behavior. +- tests/routing/policy-execution.test.ts: replace the fallthrough expectation at + :237; missing and empty reserved policies throw the distinct class; valid alias, + no-eligible trace, native slash routing, bare fallback and concrete target + non-recursion remain unchanged. +- tests/routing/routing-policy-surface-parity.test.ts: handler-level missing-policy + cases for Responses/Chat/Messages, streaming and nonstreaming; assert 404/type + and zero adapter dispatch. Add compact coverage using its existing handler + fixture owner, identified before implementation. No inference fixture. + +Parent verification: direct TypeScript checker already selected in 000; focused +regressions in remote CI at exact head; API/browser evidence for annotation via +the existing UI owner; unchanged ledger evidence. Do not run local tests/hooks. +User-facing contract docs need a small update in the parent's normal docs scope; +document unresolved selector semantics and missing policy 404, not model support. + +## Risks and acceptance + +Do not mistake unclassified history for confirmed attribution. Truncated traces +and retargeted attempts intentionally remain outside positive classification. +Do not broaden the pricing suppression to bare fallback models or make a provider +catalog the historical oracle. Bare fallback marker tests must assert unchanged +pricing alongside the annotation. Keep all ledger writes and quota changes out. + +Accept when proven slash fallback rows are visibly qualified under their recorded +provider, preserve tokens/counts, and no longer inherit a foreign vendor-only +price; normal bare/explicit-aggregator pricing is unchanged; reserved missing +policies return compatible errors before dispatch; Summary and Logs use one rule. diff --git a/devlog/_plan/260905_provider_usage_quota_parity/020_account_quota_api.md b/devlog/_plan/260905_provider_usage_quota_parity/020_account_quota_api.md new file mode 100644 index 0000000000..bf103a4355 --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/020_account_quota_api.md @@ -0,0 +1,626 @@ +# 020 — Account quota API prerequisite for 030 UI + +Status: proposed, awaiting parent audit; no production implementation or tests executed. +Work class: **C4**, because credential selection and management DTOs cross a security boundary. +Owner: parent runs one audited implementation cycle per layer; this document does not activate +or modify orchestration, goal, or loop state. This is a public feature contract derived from +existing source, not a vulnerability assessment or an advisory. Any new unreleased security +finding belongs in ignored scratch space, not this unit. + +## 1. Required outcome and exclusions + +All already-supported quota APIs are required, not optional follow-up work: + +- Preserve Anthropic, Kiro, Antigravity per-account quota reads. +- Add explicit-account xAI, Cursor, Kimi, and Command Code quota reads using their existing APIs. +- Preserve Meta Muse's separate passive observation path, including account identity and age. +- Enrich API-key rows through the existing provider quota dispatch on isolated per-key config. +- Emit `quotaMode` on every OAuth/key row, including the cheap list, and settle explicit refresh + only when enrichment finishes. Initial account controls must not wait for upstream requests. +- Keep provider/current-account reports distinct from a list of all credentials. Do not sum + API-key quotas: different keys may share the same upstream balance or subscription. + +No new provider, upstream endpoint, management pathname, dependency, auth/login flow, inference, +active-account switch, key activation, config persistence, or quota-routing strategy is added. +Ordinary account-scoped OAuth renewal may persist a rotated credential through the existing +OAuth owner; that is not permission to change `activeAccountId` or rewrite provider config. + +The user's later contract request is authoritative: row-level mode is preferred; no top-level +mode is needed. `020_account_quota_api.md` precedes the parent's 030 UI plan. The older filenames +in `000_plan.md` need a parent-owned roadmap update; this bounded task changes only this file. + +## 2. Grounded baseline and reuse decisions + +Anchors refer to source inspected on 2026-09-05; refresh line numbers before implementation. + +| Owner | Existing behavior / anchor | Reuse or precise delta | +| --- | --- | --- | +| `src/providers/quota.ts:2392` `maybeFetchProviderQuota` | Auth/name/destination dispatch; disabled providers return null | Preserve branch precedence; extract key-reader selection once for both capability and dispatch | +| `src/providers/quota.ts:2477` `fetchProviderQuotaReports` | Provider-wide cache and routing-cache publication | Never invoke this aggregator once per API key | +| `src/providers/quota.ts:1512` `supportsPerAccountQuota` | Anthropic, Kiro, Antigravity only | Add four dedicated readers before enabling their allowlist entries | +| `src/providers/quota.ts:1553` `hasPassiveAccountQuota` | Meta Muse only | Keep independent; never insert Muse into probe capability | +| `src/providers/quota.ts:1690` `getTokenForAccountQuotaProbe` | Stored token or account-scoped renewal; background local-CLI guard | Retain guard; build paired token/metadata context above it | +| `src/oauth/index.ts:569,582` | Account-scoped token/full-snapshot resolvers | Reuse, do not change OAuth renewal implementation | +| `src/providers/quota.ts:1701,1786` | Account TTL, in-flight joins, last-good, `Promise.all` roster | Add explicit dispatch, bounded workers and force-aware settlement | +| `src/server/management/oauth-account-routes.ts:254` | Cheap OAuth projection, opt-in quota and refresh | Add mode to cheap projection, then join enrichment by account id | +| `src/server/management/oauth-account-routes.ts:571` | Masked API-key list | Same pathname gains opt-in quota enrichment | +| `src/providers/api-keys.ts:66` `listProviderApiKeys` | Calls mutating `ensurePool`, even for a legacy GET | Make list projection pure; preserve virtual legacy row and mutation paths | +| `gui/src/hooks/useProviderAccountPools.ts:76,125` | Cheap OAuth list plus detached enrichment; key list only | 030 consumes mode, adds key enrichment and await-on-explicit-refresh | +| `gui/src/pages/Providers.tsx:225` | Refresh fires OAuth list for any provider and settles on provider report | 030 separates account enrichment outcome from provider-report outcome | +| `gui/src/components/provider-workspace/types.ts:43,58` | OAuth quota fields; key rows lack them | 030 mirrors additive common fields on both row types | +| `structure/05_gui-and-management-api.md:13,125-137` | Management admission, masked lists, active-key routing, unknown quota | Preserve admission; update contract documentation after implementation | + +No-code options: doing nothing leaves missing required readers; deletion hides useful readings; +configuration cannot change account binding; reuse is selected for readers, token resolution, +normalizers, key resolution, report display and existing routes. A parallel HTTP client is not +justified. A small quota-owned scheduler and isolated key-cache module are justified by scope: +`src/oauth/token-guardian.ts:115` has a private per-call worker loop, not a reusable global quota +limiter. Do not import token-guardian lifecycle into the quota path. + +## 3. C4 feature security model and must-pass controls + +This section specifies controls for the new feature, not claims that existing production code +has a newly discovered defect. No exploit steps, live account data, or severity assessment here. + +| Dimension | Feature contract | +| --- | --- | +| Assets | OAuth/key secrets, paired project/user metadata, active selection, config, cached quota attribution | +| Entrypoints | Existing authenticated GET account/key routes and provider-report reads; local secret resolution; upstream quota payloads | +| Boundaries | Browser to management API; management to credential store; resolved credential to fixed provider destination; untrusted quota JSON to safe DTO/cache | +| Adversarial inputs | Unauthenticated calls, edited provider destinations, malformed quota payloads, redirect responses, duplicate refresh calls, concurrent credential/config changes | +| Assumptions | Existing management admission remains authoritative; same-OS-user malicious processes are not isolated by this feature (`structure/02_config-and-codex-home.md:64`) | +| Controls | Exact provider reader allowlist, canonical destinations, explicit account context, identity/epoch fences, bounded work, explicit safe serialization | + +Before implementation, parent audits these must-pass checks: + +1. Unauthorized/data-plane-only callers cause zero credential resolution or upstream quota calls. +2. A quota read never calls `setActiveAccount`, key activation, `saveConfig*`, login, or inference. +3. Only a reader matched by capability dispatch receives a token. Generic unsupported dispatch + returns before resolving any credential; fallback is never another provider's reader. +4. Token and user/project metadata come from the same requested account and credential revision. + Never reread `getCredential(provider)` after an await to discover current metadata. +5. Keep existing canonical origins/paths. New fanout must not follow redirects or accept a client + supplied upstream URL; apply redirect rejection to the extracted readers without new hosts. +6. Serialize allowlisted quota numbers/windows, mode and safe flags only. No token, refresh token, + token generation, secret digest, organization/user/project id, raw upstream error or headers in + added DTO fields, logs, snapshots, screenshots, or fixtures committed with real credentials. +7. Non-finite/malformed quota, failed refresh, missing metadata and stale identity remain unknown + or unavailable, never fabricated zero. A genuine zero is still a valid measured value. +8. Bound work across simultaneous account/key requests; preserve per-request JSON size/time bounds. +9. Renewals retain existing locks and generation rules; do not force OAuth renewal merely because + the operator requested a fresh quota reading. + +Security review remains pending until code, negative regressions and the parent's permitted +verification gates exist. Reading security guidance is not evidence that a patch is secure. + +## 4. Additive row DTO contract (locked with UI owner) + +Canonical source: `src/providers/quota-types.ts` (currently `ProviderQuota`, line 26). + +```diff + export interface ProviderQuota { /* existing shape unchanged */ } ++export type AccountQuotaMode = "probe" | "passive" | "unsupported"; ++export interface AccountQuotaFields { ++ quotaMode?: AccountQuotaMode; ++ quota?: ProviderQuota | null; ++ quotaUnavailable?: boolean; ++} +``` + +Optional in the TypeScript/wire contract for older-server compatibility; the new server **always +emits `quotaMode`** on every cheap and enriched OAuth/key row. Omission by an older server means +unknown capability, not permission for a client to guess/probe. Existing `id`, alias/email masking, +`masked`, `active`, health fields, `activeAccountId` and key `activeId` retain their exact meaning. + +| Response case | Fields added to a row | +| --- | --- | +| Cheap list, any mode | `quotaMode` only; omit `quota` and `quotaUnavailable` | +| Probe enrichment succeeded | `quotaMode: "probe"`, `quota`, `quotaUnavailable: false` | +| Probe returned authoritative empty | `quotaMode: "probe"`, `quota: null`, `quotaUnavailable: false` | +| Probe failed / not admitted / identity changed | `quotaMode: "probe"`, bounded last-good quota or null, `quotaUnavailable: true` | +| Passive with observation | `quotaMode: "passive"`, `quota` with original `updatedAt`; omit failure flag | +| Passive without observation | `quotaMode: "passive"`; omit both enrichment fields | +| Unsupported, even with quota/refresh query | `quotaMode: "unsupported"`; omit both enrichment fields; no probe | + +Only an existing authoritative-empty sentinel proves empty. An arbitrary null, absent data or +malformed body is a failed/unknown probe, not an authoritative empty. Preserve existing terminal +failure behavior: drop last-good for that target and return null/unavailable. Never lose these +distinctions when converting `ProviderQuotaProbeResult` to a row. + +Full shape propagation: + +```text +quota-types.ts AccountQuotaMode + AccountQuotaFields + unchanged ProviderQuota + -> quota.ts ProviderAccountQuota (internal accountId, quota, unavailable; add mode) + -> oauth-account-routes.ts projectAccounts() / masked key projection + unavailable -> quotaUnavailable, accountId/keyId -> existing row id join + -> JSON {activeAccountId, accounts:[...]} / {activeId, keys:[...]} + -> 030 useProviderAccountPools.ts OAuthAccount / ApiKeyEntry + -> Providers.tsx accountSets / keyPools -> ProviderDetails -> ProviderAuthPanel + -> 030 shared quota display, credits/details and explicit loading state + +provider report path stays separate: +fetchProviderQuotaReports -> ProviderQuotaReport.quota + observed + aggregation + -> ProviderWorkspaceShell -> freshQuotaReport* -> ProviderCapacityQuota +``` + +Backend `ProviderApiKeyInfo` can extend `AccountQuotaFields` via a type-only import; the list +owner does not import the runtime quota aggregator. The route decorates it with computed mode. +030 mirrors the shape using existing `AccountQuota`, which already admits the ProviderQuota +windows/credits shape; avoid a runtime GUI import of the server quota module. + +## 5. Capability selection and fail-closed dispatch + +### 5.1 OAuth modes + +`supportsPerAccountQuota(provider)` keeps its exported signature. Final probe set: +`anthropic`, `kiro`, `google-antigravity`, `xai`, `cursor`, `kimi`, `command-code`. +`hasPassiveAccountQuota(provider)` stays exactly `meta-muse`. Every other OAuth id is unsupported +for this API even if it supports login or inference. Canonical `openai` retains its separate Codex +account API and must not enter this generic fanout. + +Add `providerOAuthAccountQuotaMode(provider: string): AccountQuotaMode` near these predicates: + +```ts +if (hasPassiveAccountQuota(provider)) return "passive"; +return supportsPerAccountQuota(provider) ? "probe" : "unsupported"; +``` + +Capability means an implemented reader, not a guarantee that this account is authenticated or +has a measurement. Credential errors therefore return probe/unavailable, not unsupported. +Configured destination rejection is a no-send precondition, not a fallback opportunity. + +```diff +-const wantQuota = queryQuota && supportsPerAccountQuota(provider); +-if (!wantQuota && !passiveQuota) return jsonResponse(projectAccounts()); ++const quotaMode = providerOAuthAccountQuotaMode(provider); ++// projectAccounts always copies quotaMode onto each safe summary row. ++if (!queryQuota || quotaMode === "unsupported") return jsonResponse(projectAccounts()); ++const rows = quotaMode === "passive" ++ ? readPassiveProviderAccountQuotas(provider) ++ : await fetchProviderAccountQuotas(provider, forceRefresh, config.providers[provider]); +``` + +### 5.2 API-key modes: one selector, not a second allowlist + +Extract the existing key branches in `maybeFetchProviderQuota` into +`keyQuotaReaderForProvider(name: string, provider: OcxProviderConfig): KeyQuotaReader | null`. +`KeyQuotaReader = (name: string, provider: OcxProviderConfig) => Promise`. +This pure selector checks existing name/auth/adapter rules **and each existing canonical URL +predicate**, but does not resolve a secret. It returns the existing fetcher (or the Kimi/Command +Code explicit-key adapter below), not a new implementation. `providerApiKeyQuotaMode(name, +provider)` returns probe iff the selector returns a reader; else unsupported. Never passive. + +Preserve precedence and aliases from `quota.ts:2415-2469`: + +| Existing gate | Reader retained | +| --- | --- | +| Explicit key + canonical Kimi Code base (any configured name) | `fetchKimiQuota` | +| `commandcode` + canonical Command Code base | `fetchCommandCodeQuota` | +| `registryEntryForProviderDestination(provider)?.id === "opencode-go"` | `fetchOpenCodeGoQuota` | +| Canonical A6API base (any configured name) | `fetchA6apiQuota` | +| `openrouter`, `deepseek`, `cline-pass` + corresponding canonical base | Their existing readers | +| `zai`, `glm`, `glm-cn`, `zhipu-bigmodel-coding` + canonical ZAI base | `fetchZaiQuota` | +| `minimax`, `minimax-cn` + canonical MiniMax base | `fetchMinimaxQuota` | +| `moonshot`, `venice`, `synthetic`, `deepinfra`, `neuralwatt` + respective canonical base | Their existing readers | + +Default/missing auth stays key where the existing branches allow it; preserve Kimi's stricter +explicit-key gate. Reject disabled, OAuth, forward and local modes. Missing/unresolved key on an +otherwise supported destination is probe/unavailable; the predicate must not access keychain or +environment just to label a cheap row. Do not broaden name-based readers to arbitrary aliases +in this change. Do not infer quota capability from model catalog or pricing metadata. + +## 6. OAuth reader signature changes and account context + +### 6.1 One immutable account context + +Keep `getTokenForAccountQuotaProbe(provider, accountId): Promise` for its existing guarded +renewal behavior. Add private `resolveAccountQuotaContext(provider, accountId)` in `quota.ts`: + +```ts +type AccountQuotaContext = Readonly<{ + provider: string; + accountId: string; // opaque local store handle + accessToken: string; + upstreamAccountId?: string; // xAI metadata only; never DTO + projectId?: string; // Antigravity metadata only; never DTO +}>; +``` + +After the guarded token resolution, read `getAccountCredential(provider, accountId)` exactly +for that id, require `stored.access === accessToken`, and copy token + metadata synchronously +with no intervening await. Otherwise return unavailable (do not borrow active metadata or retry +another account). This avoids changing global `OAuthAccessSnapshot`, whose current fields do +not contain xAI's upstream user id (`src/oauth/index.ts:79`). Capture config generation and row +operation epoch before awaiting; recheck ownership before cache commit and HTTP projection. +Kiro stays on `kiroUsageContextForAccount(accountId)`, preserving its account-scoped metadata and +special CLI renewal behavior. Antigravity gets token/project from this paired context, not two +unrelated reads. Keep the existing background local-CLI guard; do not broaden token renewal as +an incidental capability change. + +### 6.2 Exact before/after signatures + +All listed functions are in `src/providers/quota.ts`; existing pure parsers remain unchanged. +The four dedicated readers receive required explicit credentials: no optional-token fallback. + +```diff +-fetchXaiQuota(provider: string): Promise ++fetchXaiQuota(provider: string, context: Pick): Promise +-fetchCursorQuota(provider: string): Promise ++fetchCursorQuota(provider: string, accessToken: string): Promise +-resolveKimiQuotaBearer(config: OcxProviderConfig): Promise ++resolveKimiQuotaBearer(config: OcxProviderConfig, accountId?: string): Promise +-fetchKimiQuota(provider: string, config: OcxProviderConfig): Promise ++fetchKimiQuota(provider: string, config: OcxProviderConfig, accessToken: string): Promise +-resolveCommandCodeQuotaBearer(config: OcxProviderConfig): Promise ++resolveCommandCodeQuotaBearer(config: OcxProviderConfig, accountId?: string): Promise +-fetchCommandCodeQuota(provider: string, config: OcxProviderConfig): Promise ++fetchCommandCodeQuota(provider: string, config: OcxProviderConfig, bearer: string): Promise +-fetchAccountQuota(provider: string, accountId: string, forceRefresh: boolean): Promise ++fetchAccountQuota(provider: string, accountId: string, forceRefresh: boolean, providerConfig?: OcxProviderConfig): Promise +-fetchProviderAccountQuotas(provider: string, forceRefresh = false): Promise ++fetchProviderAccountQuotas(provider: string, forceRefresh = false, providerConfig?: OcxProviderConfig): Promise +``` + +Existing public callers with two arguments stay valid. Kimi/Command bearer resolvers now require +an explicit id for OAuth: missing id returns null, never resolves the active account internally. +Key mode still resolves only the `config.apiKey` passed in. Account fanout already has its paired +context and passes its bearer directly, without a second resolution. + +For Kimi/Command account fanout, use the supplied configured provider (copied before awaits). +If a legacy internal caller supplies no providerConfig, construct an ephemeral OAuth config +from the exact built-in registry entry's adapter/baseUrl. No disk config read/write, new default +origin, or arbitrary provider-name fallback. An explicitly supplied invalid destination is never +replaced by that default. The same canonical predicate inside the reader is retained. + +| Reader / source anchor | Explicit account behavior; preserve existing protocol | +| --- | --- | +| xAI `1244`; `fetchXaiWeeklyCredits` `1218` | Remove active `getValidAccessToken("xai")` and `getCredential("xai")`; userId = context.upstreamAccountId or `xaiUserIdFromAccessToken(context.accessToken)`. Weekly billing credits first, monthly legacy fallback with the same token. Preserve source tags and Grok headers. No userId means skip only weekly and retain existing monthly fallback. | +| Cursor `2088-2248` | Remove active token resolution; reuse one passed token for current-period Connect RPC, usage summary, then auth usage. Preserve header/body shapes, precedence, reset calculation, custom windows, `reverseEngineered: true` and source tags. No account-dependent active-store reads. | +| Kimi `1919-1945` | Move bearer acquisition to caller; pass paired account bearer to canonical `/coding/v1/usages`. Preserve `parseKimiQuotaPayload` and `kimi:usages`; key adapter resolves only its isolated key. | +| Command Code `2027-2084` | Move bearer acquisition to caller; same token for whoami, credits, subscriptions and usage summary. Derive orgId only from that token's whoami; propagate the resulting orgQuery unchanged through `fetchCommandCodeSpend`. Preserve periodStart filter, purchased-credit expiry rule, null/terminal semantics and `command-code:credits`. | + +`fetchXaiWeeklyCredits(accessToken, userId)`, `fetchCommandCodeJson(url, bearer)` and +`fetchCommandCodeSpend(bearer, credits, orgQuery)` already accept explicit context and keep their +signatures. No global organization cache is introduced. Optional spend failure must not discard +valid rolling windows or substitute lifetime spend for period spend. + +### 6.3 Dispatch and current-account reports + +```diff +-if (provider === "google-antigravity") { /* ... */ } +-else { quota = await fetchAnthropicUsageQuota(token); } ++switch (provider) { ++ case "anthropic": /* explicit Anthropic token reader */ break; ++ case "kiro": /* existing account-scoped Kiro snapshot */ break; ++ case "google-antigravity": /* paired project + bearer */ break; ++ case "xai": /* fetchXaiQuota(provider, context) */ break; ++ case "cursor": /* fetchCursorQuota(provider, context.accessToken) */ break; ++ case "kimi": /* fetchKimiQuota(provider, config, context.accessToken) */ break; ++ case "command-code": /* fetchCommandCodeQuota(provider, config, context.accessToken) */ break; ++ default: return unsupportedWithoutResolvingCredentials(); ++} +``` + +The snippet denotes control flow, not invented production function names. Put the unsupported +guard before context resolution; the default branch independently fails closed. Convert report +results to quota entries without conflating sentinels. Optional internal source/reverseEngineered +fields may be retained where current-report reconstruction needs them; do not leak metadata. + +Update the four corresponding `maybeFetchProviderQuota` OAuth branches: capture the active id +once at entry, resolve that account context, then call the explicit reader. After awaits, verify +that the same account still owns current selection before publishing a current-account report. +Do not discard a valid all-account row solely because active selection changed: its identity is +the requested account, not the global active cursor. Preserve existing Anthropic/Kiro cache +seeding and Codex report logic; do not create another current-account lookup in the reader. + +Account field chain: + +```text +requested provider + stored account.id + -> getTokenForAccountQuotaProbe (existing guarded account renewal) + -> same-id credential read + access equality -> copied AccountQuotaContext + -> explicit provider switch -> existing canonical reader/parser + -> typed probe result -> identity/operation fence -> account cache entry + -> ProviderAccountQuota.accountId + quota + unavailable + quotaMode + -> fresh safe account roster -> by-id join -> row DTO +``` + +## 7. API-key isolation, pure listing and shared dispatch + +### 7.1 Pure cheap list; preserve virtual legacy key + +Change `listProviderApiKeys` at `src/providers/api-keys.ts:66` to derive, not seed live config: + +```diff +-const pool = ensurePool(provider); +-const activeId = activeEntryId(provider); ++const pool = provider.apiKeyPool?.length ++ ? provider.apiKeyPool ++ : provider.apiKey ? [{ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }] : []; ++const activeId = (pool.find(entry => entry.key === provider.apiKey) ?? pool[0])?.id ?? null; +``` + +Keep `ensurePool` and active-key mirroring on explicit mutation paths. Existing legacy-list test +(`tests/providers/provider-api-keys.test.ts:44`) requires a masked row, not a config write. Add +assertions for unchanged in-memory config and unchanged disk content. Do not resolve environment +or keychain during the cheap list. An empty or absent legacy pool keeps identical wire identity. + +### 7.2 New key-account owner, no aggregator recursion + +Add `src/providers/quota-key-accounts.ts` for key-row snapshots/cache/fanout. Type-only quota +imports; inject a narrow probe callback from `quota.ts` to avoid a runtime import cycle. Keep +`fetchProviderApiKeyQuotas(config, name, forceRefresh = false)` as the quota facade export. +Internal output: `{ keyId: string; quota: ProviderQuota | null; unavailable?: true; isCurrent: () => boolean }[]`. +The closure captures the private resolved-credential/destination identity and clear epoch, +and revalidates them synchronously at the final route join. It is internal only: the route +copies `quota` and `quotaUnavailable` explicitly into the safe DTO, never spreads the internal +row or serializes its captured identity. A changed env/keychain value with the same row ID +invalidates the row. Test replacement between probe settlement and final projection. + +```diff + if (url.pathname === "/api/providers/keys" && req.method === "GET") { + // existing name/config validation stays first +- return jsonResponse(listProviderApiKeys(config, name)); ++ const projection = projectKeyRowsWithMode(config, name); ++ if (!queryQuota || projection.keys.every(k => k.quotaMode !== "probe")) ++ return jsonResponse(projection); ++ const rows = await fetchProviderApiKeyQuotas(config, name, forceRefresh); ++ // Reproject live safe rows; do not revive a deleted key or apply old-key data. ++ return jsonResponse(joinStillMatchingKeyQuotaRows(config, name, rows)); + } +``` + +`projectKeyRowsWithMode` / `joinStillMatchingKeyQuotaRows` are proposed route-local helpers; they +copy only safe fields. No client-supplied key, account credential, provider object, URL, or org id +is accepted by GET. `?refresh=1` without `?quota=1` remains a cheap list, not an implicit probe. + +Per-key probe callback uses the **existing uncached dispatch**, not report aggregation: + +```ts +const isolatedProvider = { + ...providerSnapshot, + apiKey: resolvedSelectedKey, + apiKeyPool: undefined, +}; +const isolatedConfig = { ...configSnapshot, providers: { [name]: isolatedProvider } }; +// name/auth/adapter/destination were admitted by keyQuotaReaderForProvider. +const result = await maybeFetchProviderQuota(name, isolatedProvider, isolatedConfig, false); +``` + +Create new objects, never assign into `config.providers[name]`. Resolve exactly the selected +pool entry through `resolveProviderApiKey`; an unresolved entry returns unavailable and must not +try another pool entry or the active key. Clear apiKeyPool in the isolated provider so no nested +reader can walk siblings. The key-only dispatch must not execute Codex/OAuth/passive branches. +Resolve before taking a cache identity so changed env/keychain values cannot reuse the previous +credential's row. Never serialize the isolated objects or pass them to persistence. + +Key field chain: + +```text +validated configured provider name -> read-only pool/legacy snapshot + -> {entry.id, entry.key reference} -> resolve only that key -> private cache identity + -> cloned provider(apiKey=that key, no pool) + cloned config -> existing dispatch + -> report.quota / null / terminal / authoritative-empty -> isolated row cache + -> still-matching live key identity -> existing masked projection + mode + quota fields + -> keys[] -> 030 ApiKeyEntry -> ApiKeyRow -> ProviderAuthPanel +``` + +## 8. Cache attribution, bounded fanout and force semantics + +### 8.1 Cache ownership + +- OAuth retains `accountQuotaCache`, disk hydration and existing passive persistence. Do not put + API-key entries into `provider-account-quota-cache.json`, whose reconciliation is OAuth-only + (`quota.ts:1625`, `account-quota-disk.ts:31`). No key-cache disk schema/migration is required. +- Key rows use a separate bounded process-local map. Private identity includes namespace, provider + name, adapter, normalized base URL, auth mode, entry id and digest of the **resolved** key. The + existing short `apiKeyPoolEntryId` is a row handle, not sufficient private identity. Use the + existing crypto import pattern for a full digest; never emit it in DTOs/logs or persist it. +- Key TTL = existing `ACCOUNT_QUOTA_TTL_MS` (10 minutes); transient last-good display bound = + existing `LAST_GOOD_MAX_AGE_MS` (30 minutes). Failure advances attempt TTL, not measurement + `quota.updatedAt`. Null/terminal/authoritative-empty conversions follow section 4. +- Cap key cache at 256 entries, evict expired then least-recently-used settled entries on access + or write. No new timer. Bound in-flight/queued entries separately. Do not persist key rows. +- `clearProviderQuotaCache` invalidates key-flight commit authority as well as key settled rows. + Use a module epoch; post-await commits check epoch and current entry ownership. A changed key, + provider destination, removed row or replaced provider cannot publish stale enrichment even to + the immediate response. Reproject and revalidate before the by-id join, not only cache writes. +- OAuth caches keep their routing key shape. The new four readers' entries additionally bind to + their captured credential/context and destination identity for lookup/commit. Existing cache + consumers still obtain quota, never token metadata. Reauth/replacement cannot reuse a previous + principal's cache merely because the local row id survived. Persisted rows lacking new identity + evidence are display hints only until validated/probed, not fresh cache hits for these readers. +- API-key enrichment never calls `replaceCachedProviderQuotas`, never seeds a provider-wide + report with an inactive key, and never changes `activeId` or `provider.apiKey`. Current reports + continue using the actual active credential; no key aggregation or account weighting is added. + +### 8.2 Bounded scheduling + +Add `src/providers/quota-probe-scheduler.ts`, lazy process-local state with no startup timer: +four admitted quota transactions at once, at most 64 queued transactions; excess returns a +typed unavailable outcome. Each account/key request uses at most four worker promises pulling +the roster in input order, not one immediately executing promise per credential. All roster +rows are represented; overflow is unavailable, never silently truncated or zero. + +Acquire admission **after cache/single-flight checks and once per reader transaction**, before +token resolution/wire calls. Never acquire again inside a nested helper/dispatch: no deadlock. +Provider-level reads of the same readers use the same admission boundary; cached and passive +reads consume no permit. Existing A6API transaction performs two parallel requests, so four +transactions bound quota HTTP fanout to at most eight for the current reader set; OAuth refresh +requests remain governed by their existing refresh-flight owner. Do not claim four total network +requests or introduce a second lock around the same token renewal. + +Queue entries time out after 30 seconds and cancel without starting; active transactions keep +their permit until their bounded reader work actually settles. Preserve `REQUEST_TIMEOUT_MS=8000` +and `QUOTA_RESPONSE_MAX_BYTES=512 KiB`, including response-body bounds and finite Cursor/Command +Code fallback sequences. No retries beyond existing reader fallbacks. A UI timeout must not +release a permit while its upstream work is still running. The endpoint's total latency can +span multiple waves; do not claim it completes within one upstream timeout. + +### 8.3 Force versus in-flight requests + +Existing `fetchAccountQuota` unconditionally joins an in-flight read (`quota.ts:1710`). Replace +the flight value with `{ promise, forced, operationEpoch, identity }` and enforce: + +| Request | Cache / flight behavior | +| --- | --- | +| Ordinary | Reuse matching fresh settled entry, else join matching current flight, else schedule | +| Forced, no flight | Bypass positive and negative TTL and schedule one new probe | +| Forced, forced flight exists | Join the same identity's forced flight; no duplicate spending | +| Forced, ordinary flight exists | Elect one shared forced successor, wait for old flight settlement, then probe; do not report old cached/ordinary result as forced | +| Clear/remove/identity change | Invalidate old operation authority; no late cache or response publication under the replacement | +| Passive force | Cache read only, unchanged observation time, no token renewal, no admission slot | +| Unsupported force | Return mode only, no token resolution, no admission slot | + +The elected forced successor supersedes older write authority, but does not race a second token +renewal against the ordinary flight. `finally` removes only its own flight entry. Apply the same +semantics to the new key cache. A current report cache hit is never evidence that all account +rows refreshed. Forced quota refresh does not mean force-refreshing an otherwise valid token. + +## 9. 030 handoff: precise load and refresh settlement + +No UI implementation belongs in this backend cycle; these field chains are its consumer contract. + +```diff +-fetchAccountSets(providers: string[]): Promise ++fetchAccountSets(providers: string[], refresh?: boolean): Promise +-fetchKeyPools(providers: string[]): Promise ++fetchKeyPools(providers: string[], refresh?: boolean): Promise +``` + +Initial load: cheap GET, paint controls and per-row modes, then background `quota=1` enrichment +for probe/passive modes. Passive cache reads may occur but never show a probe skeleton or a +"freshly probed" claim. Unsupported modes do not launch a follow-up read. Preserve request- +generation guards and mounted/alive checks. Use explicit enrichment-pending state, not the +absence of quota, so authoritative-empty and unobserved passive rows terminate cleanly. + +Explicit refresh: select the endpoint from auth surface; send `quota=1&refresh=1`; await body +parsing, current-generation state acceptance and enrichment settlement. It may skip another +cheap GET if the roster is already loaded. Resolve false on HTTP/parser rejection, superseded +generation or any returned probe row with `quotaUnavailable: true`. Resolve true for settled +probe success/authoritative-empty. Passive/unsupported success means "check completed", never +"upstream quota refreshed". A successful empty roster is a completed check, not a measurement. + +If the UI keeps a combined current + all-accounts refresh button, await both requests and use +their combined outcomes; never settle all-account refresh from the provider-report waiter alone. +The existing provider-report endpoint success proves only that its read completed, not that each +upstream refreshed. Prefer separate result wording rather than changing that endpoint's meaning. +The global overview refresh continues to refresh provider reports, not every stored credential. + +Keep unavailable last-good bars visibly stale if 030 elects to display them; do not erase the +failure flag when merging. Replacement enrichment must clear an old `quotaUnavailable: true` +with `false` on successful probe. Passive uses original `quota.updatedAt` for observation age; +never stamp it with the click time. API-key rows remain a list, never a sum. + +## 10. Dependency-ordered implementation layers and exact file deltas + +The A-D rows below are implementation substeps of ONE parent-owned `quota-api` cycle, +not extra work phases. Do not enable a mode before its reader and +negative tests land. Production edits listed below are planned, not made by this document task. + +| Layer | Files and changes | Dependency / acceptance | +| --- | --- | --- | +| A — row contract + dispatch foundation | `src/providers/quota-types.ts`: mode/fields; `src/providers/quota.ts`: mode predicates, explicit unsupported branch, pure key reader selector; `src/server/management/oauth-account-routes.ts`: cheap row mode; `src/providers/api-keys.ts`: pure legacy projection and type-only quota fields | Existing supported modes only until B; cheap GET does no upstream/secret/config writes | +| B — four OAuth readers | `src/providers/quota.ts`: exact signatures in section 6, paired context, active-report call sites, sentinel conversion, four allowlist additions, flight fences; `src/providers/quota-probe-scheduler.ts` (new): shared bounded admission | A; every dedicated reader is tested with at least two distinct accounts, no active switch | +| C — all key rows | `src/providers/quota-key-accounts.ts` (new): isolated config/cache/fanout; `src/providers/quota.ts`: facade and uncached callback, cache invalidation integration; `src/server/management/oauth-account-routes.ts`: key opt-in enrichment | A+B scheduler; every existing supported key dispatch admitted; no provider cache contamination | +| D — backend contract audit | Existing backend regression files below; `structure/05_gui-and-management-api.md`: row modes/query semantics/refresh outcome (parent scope); `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json` if new backend regression files are added | A-C; DTO/privacy/race checks and parent exact-head CI before 030 consumption | +| 030 — UI consumer, separately owned | `gui/src/hooks/useProviderAccountPools.ts`, `gui/src/components/provider-workspace/types.ts`, `gui/src/pages/Providers.tsx`, `ProviderDetails.tsx`, `ProviderAuthPanel.tsx`, `ProviderUsage.tsx`, `ProviderCapacityQuota.tsx`, `gui/src/provider-workspace/report.ts`, all affected i18n locales | Backend A-D; loading/refresh and current-vs-all rendering follows section 9 | + +Keep new modules focused and under the dev modularity limits. `quota.ts` is already large; +do not append the independent key-cache/scheduler implementations to it or opportunistically +move every existing provider parser. No changes to `src/oauth/index.ts`/store persistence are +required by this design. If paired context cannot be obtained with the existing account resolver, +parent must explicitly amend scope before changing auth internals. + +## 11. Regression matrix (planned, not executed) + +Extend existing files first. New files listed as new require both layout registry entries. +Use synthetic tokens and local mocked transports only; assertions must not print real secrets. + +| File | Required trigger cases | +| --- | --- | +| `tests/providers/provider-account-quota.test.ts` | Replace the old xAI-is-unsupported expectation at line 205 with a truly unsupported OAuth provider; two-account xAI/Cursor/Kimi/Command rows; all explicit token bindings; unchanged active id; missing/expired token; metadata/token mismatch; missing project; preserved Anthropic local-CLI guard; success-failure-success flag clearing | +| `tests/providers/provider-quota.test.ts` | Existing current reports retain source/window/credit shape; active switch during await cannot publish old current identity; exact key destination/name/auth selector table; no-new-alias behavior; unknown/local/forward/disabled destination performs zero sends; provider current report unchanged after inactive key enrichment | +| `tests/providers/command-code-quota.test.ts` | Two tokens produce distinct whoami orgs; same token + org across credits/subscription/period summary; no active org leak; missing whoami preserves existing unscoped behavior; optional spend failure preserves windows; purchased credits suppress expiry; terminal failure clears last-good; key and OAuth readers agree for equivalent fixtures | +| `tests/providers/opencode-go-quota.test.ts` | Renamed canonical key provider remains supported; wrong adapter or lookalike destination stays unsupported; each isolated key gets its own usage | +| `tests/providers/provider-api-keys.test.ts` | Cheap legacy row without in-memory/disk mutation; masked serialization; `quota=1`, `quota=1&refresh=1`, refresh-only cheap behavior; two keys differing quota; inactive key does not become active; empty pool; unresolved active key never falls back to sibling; key read no token/keychain/config write | +| `tests/oauth/oauth-accounts-api.test.ts` | Cheap rows always carry mode, no quota work; opt-in probe merges after health reproject; same field names for initial/refresh response; request with unknown provider rejected before any probe; existing management admission preserved; row removed during probe stays removed | +| `tests/providers/muse-passive-quota-cache.test.ts` | Mode passive while supportsPerAccountQuota remains false; hydration before persistence; account revision fence; observed roster only; no-observation omitted, not error; restart retains observation | +| `tests/providers/muse-passive-quota-observation.test.ts` | Cheap list mode only; enriched passive row mode + original quota timestamp; forced read makes zero network/renewal calls; unobserved passive row has no unavailable flag; active/current selection remains distinct from stored-account observations | +| `tests/providers/provider-quota-observed-marker.test.ts` | Preserve provider-report observed marker and freshness exemptions | +| `tests/providers/kiro/kiro-account-quota.test.ts` | Existing Kiro context/CLI refresh, regional metadata and exhaustion-state commit remain intact after scheduler introduction | +| `tests/providers/provider-account-quota-persistence.test.ts` | No API-key cache entries/digests in OAuth disk snapshot; OAuth/passive hydration unchanged; old persisted new-reader row cannot masquerade as a freshly verified identity | +| `tests/providers/provider-key-account-quota.test.ts` (new) | Full supported key-reader matrix; cross-provider same id; key replacement same id; env/keychain reference resolves to new key; base/auth/adapter change; deletion/readdition; config freeze; no cache bleed to current report/OAuth; transient vs terminal vs authoritative-empty; force and negative TTL; cache cap/eviction | +| `tests/providers/quota-probe-scheduler.test.ts` (new) | Four transaction ceiling across overlapping batches; A6API two-request accounting; queue cap/timeout; passive/unsupported/cache hit takes no permit; FIFO/no starvation within admitted queue; rejection/finally releases exactly once; no nested-lock deadlock; overflow rows unavailable; no abandoned task releases early | + +Additional reader fixtures within those files must force each existing protocol branch: + +1. xAI weekly success, missing upstream user id with monthly fallback, token-derived user id, + weekly failure then same-token monthly fallback, malformed/non-finite amounts. +2. Cursor period total versus secondary custom windows, summary fallback, auth-usage fallback, + each with distinct two-account tokens, date parsing, and redirect rejection. +3. Kimi custom/standard window parsing, canonical configured base, explicitly invalid base, + OAuth token versus isolated coding-plan key, missing/invalid payload. +4. Force during an ordinary flight starts exactly one successor; concurrent forced requests join; + late ordinary result cannot overwrite forced result; remove/clear during either flight cannot + revive a row. Successful force bypasses both positive and negative ten-minute TTL. +5. One failed row does not drop healthy siblings; one malformed upstream body cannot serialize raw + fields. Real 0%, empty/no windows, unsupported and unavailable remain four distinct cases. + +030 regression consumers: `gui/tests/provider-quota-refresh-controls.test.tsx`, +`gui/tests/provider-quota-refresh-settle.test.tsx`, `gui/tests/auth-panel-refresh-render.test.tsx`, +`gui/tests/provider-capacity-shell.test.tsx`, `gui/tests/provider-capacity-credits.test.tsx`, +`gui/tests/provider-quota-observed-freshness.test.ts`, `gui/tests/quota-observed-age.test.tsx`. +Required UI cases: initial controls paint before delayed quota; explicit refresh awaits delayed +account AND key enrichment; partial failure is not success; passive no skeleton/probe; unsupported +no follow-up; success clears stale failure; credits-only/coverage-only renders; no key summation; +provider navigation rejects an old generation's enrichment; all locale keys follow GUI policy. + +## 12. Verification ownership and handoff gate + +No local tests or suites are permitted by `000_plan.md` or this task. The parent owns remote +exact-head CI and the audit cycle. Candidate focused CI invocations (not run here): + +```sh +bun test tests/providers/provider-account-quota.test.ts tests/providers/provider-quota.test.ts tests/providers/command-code-quota.test.ts +bun test tests/providers/provider-api-keys.test.ts tests/providers/provider-key-account-quota.test.ts tests/providers/quota-probe-scheduler.test.ts +bun test tests/oauth/oauth-accounts-api.test.ts tests/providers/muse-passive-quota-cache.test.ts tests/providers/muse-passive-quota-observation.test.ts +bun test tests/providers/kiro/kiro-account-quota.test.ts tests/providers/provider-account-quota-persistence.test.ts tests/providers/provider-quota-observed-marker.test.ts tests/providers/opencode-go-quota.test.ts +``` + +Parent may run its approved static checks (existing documented direct TypeScript checker and +privacy scan), never use these command examples as permission to run local tests. Existing +exact-head CI owns broader platform/layout/privacy gates; no new workflow or dependency here. + +020 is ready for 030 implementation only after the parent has evidence for all of: + +- [ ] Cheap OAuth and key rows emit the agreed mode without upstream work or config mutation. +- [ ] Seven probe OAuth readers and passive Muse are distinct and account-correct. +- [ ] Every existing supported key reader works per isolated key, with bounded fanout and no sum. +- [ ] Explicit force settles enrichment with correct cache/in-flight/failure semantics. +- [ ] Tokens/metadata/digests never enter added response fields; canonical destinations preserved. +- [ ] Current provider reports remain unchanged by inactive-account/key reads. +- [ ] Parent security audit and exact-head regression evidence are attached to its ledger. + +This document's readiness is design-only. No box is checked by writing the plan. + +## 13. Parent scope lock (supersedes broader scheduling proposals above) + +Keep the feature focused on credential-scoped retrieval and DTO correctness. A new global +queue/admission framework and forced-successor protocol are not required by the user outcome. +Do NOT add quota-probe-scheduler.ts, queue timers, global provider-report scheduling changes, +or its proposed test file. Reuse a local four-worker roster mapper (existing concurrency helper +if found); per-identity single-flight deduplicates concurrent reads. State the guarantee as +four workers per roster, not a global HTTP concurrency guarantee. + +Force bypasses settled positive/negative TTL but may join an already-running same-identity +upstream read, matching the existing account-read contract. No extra successor probe is needed. +The new key cache remains bounded, process-local, credential/destination keyed and invalidated +on clear; late results must not contaminate replacement identity. Preserve existing Anthropic, +Kiro and Antigravity renewal/cache semantics; new readers bind their token plus metadata to +the selected account. Do not retrofit a new persisted OAuth identity schema in this feature. + +Section 9's options object is advisory; 030's `(providers, refresh = false)` signature is the +locked GUI interface. `refresh=true` awaits enrichment; initial loads enrich in the background. +Probe requests can join in-flight work but cannot report completion before it settles. diff --git a/devlog/_plan/260905_provider_usage_quota_parity/030_quota_views.md b/devlog/_plan/260905_provider_usage_quota_parity/030_quota_views.md new file mode 100644 index 0000000000..05aed92e96 --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/030_quota_views.md @@ -0,0 +1,81 @@ +# Current-account and all-account quota views + +Depends on 010 attribution and 020 account quota API. Execute as work-phase `quota`. +Contract/resources/terminal conditions inherit 000. Class C3. No local test execution. + +## Existing owners and changes + +MODIFY `gui/src/provider-workspace/report.ts`: add a pure current-account projection next to `accountQuotaFromReport`. For a valid pool aggregation, use only `aggregation.currentAccount.quota`; never fall back from missing current quota to aggregate capacity. For non-pool reports, use the report's quota. Return a report-shaped view without aggregation so existing credit rendering is reusable. + +```diff ++export function currentAccountQuotaReport(report?: ProviderQuotaReportView): ProviderQuotaReportView | undefined { ++ if (!report) return undefined; ++ const aggregation = capacityAggregationFromReport(report); ++ if (!aggregation) return report; ++ return { ...report, aggregation: undefined, quota: aggregation.currentAccount?.quota ?? null }; ++} +``` + +NEW `gui/src/components/provider-workspace/ProviderCurrentQuota.tsx`: shared current-account section. Props are `report?: ProviderQuotaReportView`, `onRefreshQuota?: () => Promise`. Render the localized current-account heading, existing `ProviderCapacityQuota` with current-only projected report, source/updated age metadata and the existing asynchronous refresh control. Missing quota uses translated unavailable text; credit-only quota must render a balance. Move the current refresh busy/result logic from ProviderUsage, preserving failure/settlement semantics and accessibility. Do not add a network client. + +MODIFY `ProviderOverview.tsx`: remove the old left-side capacity block; insert `ProviderCurrentQuota` directly below the right-side usage statistics. Accept the refresh callback and forward it. Aggregate dashboard `ProviderOverviewDashboard` remains provider-wide capacity. + +MODIFY `ProviderUsage.tsx`: replace its bottom rate-limit block and duplicated refresh state/handlers with the same `ProviderCurrentQuota`; preserve usage metrics/model table and source semantics. Existing report adapter is the source of truth. + +MODIFY `ProviderDetails.tsx`: forward the already-scoped `onRefreshQuota` to Overview, just as Usage already receives it. Accounts receives enriched rows unchanged. + +MODIFY `gui/src/hooks/useProviderAccountPools.ts` and `gui/src/components/provider-workspace/types.ts`: both OAuth and API-key row types gain `quotaMode?: "probe" | "passive" | "unsupported"`, `quota?: AccountQuota | null`, `quotaUnavailable?: boolean`; extend key rows only where currently absent. Field chain: 020 backend cheap/enriched DTO creation -> JSON -> hook read -> row props -> shared rendering. Optional mode absent keeps old-server compatibility; unknown mode must not enable a probe. + +```diff +-const fetchAccountSets = useCallback(async (providers: string[]) => { ++const fetchAccountSets = useCallback(async (providers: string[], refresh = false) => { + // keep cheap local rows and ready controls, then enrich supported modes +-void (async () => { /* quota read, swallowed outcome */ })(); ++const enrichment = async (): Promise => { /* read quota=1, append refresh=1 when requested; ++ preserve generation fence; return false for failed HTTP or quotaUnavailable rows */ }; ++if (refresh) return await enrichment(); ++void enrichment(); +``` + +For `fetchKeyPools`, retain cheap list loading, add opt-in quota enrichment and generation/alive fences matching account lists. Merge refreshed provider subsets into existing state rather than replacing unrelated key pools. Return boolean settlement and preserve last-good rows on failures. Do not call OAuth APIs for key-auth providers. + +For BOTH credential types, merge the cheap list with previous quota by credential ID before publishing it, removing IDs no longer listed. Mark supported probe rows `quotaPending` during enrichment; HTTP/network failure sets `quotaUnavailable: true` and clears pending behind the same generation/alive fence, preserving last-good quota. Successful enrichment replaces flags from the returned row, not a spread which would retain stale errors. Passive missing observations stay unobserved. `quotaPending?: boolean` is a GUI-only derived field, never a backend/persisted credential field. This closes HTTP failure with and without last-good data. + +MODIFY `gui/src/pages/Providers.tsx`: + +```diff +-void fetchAccountSets([provider]); +-void fetchProviderQuotas(true); +-return settled; ++const accountsSettled = config?.providers[provider]?.authMode === "oauth" ++ ? fetchAccountSets([provider], true) ++ : fetchKeyPools([provider], true); ++void fetchProviderQuotas(true); ++return Promise.all([settled, accountsSettled]).then(results => results.every(Boolean)); +``` + +Codex retains its dedicated pool refresh; do not send it to the generic key/OAuth path. Reconcile current selection and clear stale refresh feedback on provider change. + +Bind `quotaRefreshWaiters` to the refresh epoch rather than settling all on any forced completion. `ProviderWorkspaceShell` includes its captured epoch in `onQuotaRefreshSettled`; the page resolves only matching tickets. Superseded earlier tickets resolve false (never success from a different request); unmount resolves all false. Extend the actual page-coordinator fixture with two deferred forced reads completing in reverse order, beyond the existing shell-only settlement fixture. Update every callback prop signature and caller found by search. + +MODIFY `ProviderAuthPanel.tsx`: use a small shared quota row component for both OAuth and API keys. Reuse ProviderCapacityQuota for credit balances and QuotaBars windows, not QuotaBars alone. Render read mode explicitly: probe pending uses existing bounded skeleton; passive without quota shows not-yet-observed, unsupported shows unsupported, failed read shows unavailable and optionally last-good quota with its age. Passive rows carry observedAt based on mode, not hardcoded provider name. Preserve switch/remove controls and existing key rows by nesting control row plus quota row like OAuth. Never sum all-account quotas. Add key refresh control in the same location/interaction pattern as OAuth. + +NEW `ProviderAccountQuota.tsx`: pure shared row props `quota`, `quotaMode`, `quotaUnavailable`, `pending`; output a credits/window report through ProviderCapacityQuota plus translated state. Add only the required unsupported/not-yet-observed/current-account usage copy in every locale (`en,de,fr,ko,zh,zh-TW,ru,ja,tr`). Existing unknown data is never presented as 0%. + +## Verification activation matrix + +- Existing `gui/tests/provider-capacity-credits.test.tsx`: current quota credit-only with and without expiry renders on both Overview and Usage; invalid date remains safe. +- Existing `gui/tests/provider-capacity-shell.test.tsx`: pool aggregate 20%, current 70% -> detail current section shows 70%, overview dashboard retains aggregate 20%; missing current is unknown, not 20%. +- Existing `gui/tests/provider-quota-refresh-controls.test.tsx`: pending -> success/failure, passive unobserved, unsupported no loading/probe, current provider change resets feedback. +- Existing `gui/tests/provider-quota-refresh-settle.test.tsx`: explicit refresh awaits enriched per-account response, sends refresh=1, rejects one account failure; key-auth never calls OAuth endpoint; stale response cannot replace another request/provider state. +- Add API-key quota row cases beside the existing account rendering tests; verify two distinct keys and credit-only quotas. +- Remote CI executes tests. Local `node node_modules/typescript/bin/tsc --noEmit`, `cd gui && node node_modules/typescript/bin/tsc -b && node node_modules/vite/bin/vite.js build`, GUI lint and privacy scan. Do not turn local manual QA into a test suite. +- Isolated dev server with synthetic API responses: Kimi/Anthropic/Codex pool/API-key/passive/unsupported; browser screenshots for Overview, Usage, Accounts and narrow Korean layout. No calls to live provider endpoints, no user credentials in screenshots. + +## Documentation + +MODIFY `structure/05_gui-and-management-api.md` usage/quota paragraphs and `docs-site/src/content/docs/guides/web-dashboard.md`: current account below usage, all supported credentials in Accounts, API cost != subscription quota, observed/unsupported/error meanings. Keep translated `*/guides/web-dashboard.md` pages non-contradictory. + +## Bypass/residual + +UI state is presentation, not credential authorization. Backend fixed-destination account/key readers are the actual boundary. Existing account mutation permissions are unchanged; no new enforcement claim. diff --git a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md new file mode 100644 index 0000000000..b5da6f82bb --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md @@ -0,0 +1,22 @@ +# Verified bottom-up stack landing + +Depends on all implementation layers. Execute as `landing`; no production patch planned. +Inherit resource/scope limits from 000. User explicitly authorizes no-verify pushes and admin merges only after CI succeeds. + +## Actions + +1. Inspect `git status --short`, `git worktree list`, each branch tip and `gh pr view --json headRefOid,baseRefName,statusCheckRollup,reviewDecision,mergeStateStatus`. +2. Inspect exact-head CI via `gh run list --commit ` and failed job logs when necessary. An empty required-check list is not proof. Resolve correct review findings without suppressing tests. +3. Ensure every PR includes Summary, Verification and Checklist, a linked stack map, explicit no-local-suite note, and UI screenshot for UI changes. Record admin bypass authorization in the PR description. +4. Merge the bottom PR only when its exact head has successful full CI; prefer `gh pr merge --admin --merge --match-head-commit ` to preserve stack ancestry. Do not delete lower branches. +5. Retarget the next child to `dev`; refresh checks at its exact head/base. If ancestry reconstruction is necessary, use only session-owned branches with clean working state, record parent and child commits, cascade all upper layers and use `--force-with-lease --no-verify`; no destructive worktree operations. +6. After each merge, `git fetch origin dev` then `git merge-base --is-ancestor FETCH_HEAD`. Record PR, CI head, merge SHA and ancestry outcome in `041_delivery.md`. +7. Archive the completed unit from `_plan` to `_fin` only as an explicit final documented source change with its own remote checks if it alters a pending PR. Otherwise retain a terminal closure record without inventing extra unverified commits. + +## Completion evidence + +All exact-head CI jobs passed, original symptom and quota state matrix observed, no user history modified, no live service restarted, no local suite executed, every authorized stack layer on fetched dev. Final report distinguishes repository delivery from runtime deployment. + +## Verifier and terminal conditions + +CLI GitHub reads are bounded, at most one fresh rollup per meaningful head/state change. Capture C receipt using the exact-head CI verification command. DONE only with all ancestry proofs; wait for pending CI using bounded polling, never call pending CI a blocker. From 9a9ad98b8f22528eaecf577c45df6314ae9b7374 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 11:20:55 +0900 Subject: [PATCH 02/10] fix(usage): distinguish unresolved fallback selectors and reject missing policies --- .../010_attribution.md | 5 +- .../011_attribution_build.md | 22 ++++ .../assets/011_usage_attribution.png | Bin 0 -> 75544 bytes .../src/content/docs/guides/web-dashboard.md | 8 ++ .../provider-workspace/ProviderUsage.tsx | 3 + .../ProviderWorkspaceShell.tsx | 13 +- .../components/provider-workspace/types.ts | 1 + gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/provider-workspace/usage.ts | 18 +++ gui/src/styles/provider-workspace-shell.css | 1 + gui/tests/provider-usage-attribution.test.tsx | 46 +++++++ src/router.ts | 17 ++- src/server/chat-completions.ts | 6 +- src/server/claude-messages.ts | 6 +- src/server/management/shared.ts | 9 +- src/usage/cost.ts | 31 +++-- src/usage/model-identity.ts | 26 ++++ src/usage/summary.ts | 16 ++- structure/05_gui-and-management-api.md | 9 ++ tests/routing/policy-execution.test.ts | 16 +-- .../routing-policy-surface-parity.test.ts | 31 ++++- .../management-api-logs-metrics.test.ts | 63 ++++++++++ tests/usage/usage-aggregate-cache.test.ts | 26 +++- tests/usage/usage-cost.test.ts | 22 ++++ tests/usage/usage-summary.test.ts | 114 ++++++++++++++++++ 33 files changed, 478 insertions(+), 40 deletions(-) create mode 100644 devlog/_plan/260905_provider_usage_quota_parity/011_attribution_build.md create mode 100644 devlog/_plan/260905_provider_usage_quota_parity/assets/011_usage_attribution.png create mode 100644 gui/tests/provider-usage-attribution.test.tsx create mode 100644 src/usage/model-identity.ts diff --git a/devlog/_plan/260905_provider_usage_quota_parity/010_attribution.md b/devlog/_plan/260905_provider_usage_quota_parity/010_attribution.md index 77e2828f70..ce3e59add3 100644 --- a/devlog/_plan/260905_provider_usage_quota_parity/010_attribution.md +++ b/devlog/_plan/260905_provider_usage_quota_parity/010_attribution.md @@ -98,7 +98,10 @@ All locale modules receive the corresponding key. Keep grouping by m.provider; recompute `shareRatio` from selected-provider token total, not the global summary, so the screenshot's nearly empty bars become meaningful within each provider. Test propagation and selected-provider shares with existing provider workspace -tests. Increment the provider usage session-cache version to prevent stale derived +tests. Extract only that existing grouping into `buildProviderModelUsage` in the +existing pure `gui/src/provider-workspace/usage.ts` owner; no parallel client or +state. NEW `gui/tests/provider-usage-attribution.test.tsx` directly exercises the +production helper plus rendered ProviderUsage. Increment the provider usage session-cache version to prevent stale derived rows after update. Backend marker alone is not UI completion. ## Diff 3 — Restrict only unsupported cross-provider price inference diff --git a/devlog/_plan/260905_provider_usage_quota_parity/011_attribution_build.md b/devlog/_plan/260905_provider_usage_quota_parity/011_attribution_build.md new file mode 100644 index 0000000000..de48ce21f1 --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/011_attribution_build.md @@ -0,0 +1,22 @@ +# Attribution implementation evidence + +Backend worker Harvey implemented the audited classifier, price-option propagation, summary +marker, Logs attempt DTO consistency and missing-policy rejection. Main implemented provider +model annotation, per-provider share calculation and the usage cache version bump. + +- Production direct TypeScript checker: exit 0. +- GUI direct typecheck/build: exit 0; existing large-chunk warning only. +- GUI i18n lint: exit 0. +- Privacy scan: passed. +- Public docs Astro build: exit 0, 425 pages; existing missing 404-entry and chunk warnings. +- Local test suites: NOT RUN, expressly forbidden; regression execution belongs to remote CI. +- Browser: actual ProviderWorkspaceShell -> ProviderDetails -> ProviderUsage components on + `http://127.0.0.1:18184/.tmp/provider-parity.html`, synthetic responses only, no live credentials + or upstream requests. Observed Korean unresolved-selector explanation and 80%/20% shares + within Kimi despite a separate provider owning 90% of global tokens; unavailable foreign + vendor cost remains a dash. Screenshot `assets/011_usage_attribution.png` read back. +- Narrow viewport revealed verbose Korean annotation; shortened copy and used normal text + font rather than inherited model monospace. Desktop re-observed cleanly after the change. + +Independent final review and exact-head CI remain pending. Existing usage history is untouched; +no new inference, service restart, or live-account mutation occurred. diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/011_usage_attribution.png b/devlog/_plan/260905_provider_usage_quota_parity/assets/011_usage_attribution.png new file mode 100644 index 0000000000000000000000000000000000000000..b49e9d1815f74007ed1a7140c723450d48372177 GIT binary patch literal 75544 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 {row.model} + {row.hasUnresolvedRequestedModel && ( +
{t("pws.unresolvedRequestedModel")}
+ )} {formatCostUsd(row.estimatedCostUsd, locale)} {formatTokenCount(row.totalTokens, locale)} diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 5b0004c920..c303f7ad43 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -23,7 +23,7 @@ import { import { providerKind } from "../../provider-workspace/kind"; import { readJsonIfOk, readJsonOrThrow } from "../../fetch-json"; import { readSessionListCache, writeSessionListCache } from "../../session-list-cache"; -import { countAvailableModels, parseAvailableModels, parseLiveModelCounts, parseSelectedModels, type ProviderAvailableModels, type ProviderLiveModelCounts, type ProviderModelCounts, type ProviderSelectedModels } from "../../provider-workspace/usage"; +import { buildProviderModelUsage, countAvailableModels, parseAvailableModels, parseLiveModelCounts, parseSelectedModels, type ProviderAvailableModels, type ProviderLiveModelCounts, type ProviderModelCounts, type ProviderSelectedModels } from "../../provider-workspace/usage"; import { freshQuotaReportRecord, freshQuotaReportsFromResponse, @@ -140,7 +140,7 @@ export default function ProviderWorkspaceShell({ const [modelsLoading, setModelsLoading] = useState(false); const [modelsLoadFailed, setModelsLoadFailed] = useState(false); const quotasCacheKey = `ocx.providers.quotas.v1:${apiBase}`; - const usageCacheKey = `ocx.providers.usage.v1:${apiBase}`; + const usageCacheKey = `ocx.providers.usage.v2:${apiBase}`; const [usageTotals, setUsageTotals] = useState>(() => ( readSessionListCache<{ totals: Record }>(usageCacheKey)?.totals ?? {} )); @@ -202,7 +202,7 @@ export default function ProviderWorkspaceShell({ useEffect(() => { let cancelled = false; const timeout = window.setTimeout(() => { - const data = usageResource.data as { providers?: Array<{ provider: string; requests: number; totalTokens?: number }>; models?: Array<{ provider: string; model: string; resolvedModel?: string; requests: number; totalTokens: number; inputTokens: number; outputTokens: number; shareRatio: number; estimatedCostUsd?: number }> } | undefined; + const data = usageResource.data as { providers?: Array<{ provider: string; requests: number; totalTokens?: number }>; models?: Array } | undefined; if (cancelled) return; if (!data) { if (usageResource.loading) setUsageLoading(!readSessionListCache(usageCacheKey)); @@ -211,12 +211,7 @@ export default function ProviderWorkspaceShell({ const byProvider: Record = {}; for (const row of data.providers ?? []) byProvider[row.provider] = { requests: row.requests, totalTokens: row.totalTokens }; setUsageTotals(byProvider); - const byProviderModels: Record = {}; - for (const m of data.models ?? []) { - const key = m.provider; - if (!byProviderModels[key]) byProviderModels[key] = []; - byProviderModels[key].push({ model: m.model, ...(m.resolvedModel ? { resolvedModel: m.resolvedModel } : {}), requests: m.requests, totalTokens: m.totalTokens, inputTokens: m.inputTokens, outputTokens: m.outputTokens, shareRatio: m.shareRatio, ...(m.estimatedCostUsd !== undefined ? { estimatedCostUsd: m.estimatedCostUsd } : {}) }); - } + const byProviderModels = buildProviderModelUsage(data.models ?? [], byProvider); setUsageModels(byProviderModels); writeSessionListCache(usageCacheKey, { totals: byProvider, models: byProviderModels }); setUsageLoading(false); diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index 5cb55c2435..f329e60ce3 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -29,6 +29,7 @@ export interface ProviderUsageTotals { export interface ProviderModelUsageRow { model: string; resolvedModel?: string; + hasUnresolvedRequestedModel?: true; requests: number; totalTokens: number; inputTokens: number; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 2c59bf95e8..0e5d171b38 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1876,6 +1876,7 @@ export const de: Record = { "pws.usageLast30d": "Nutzung (letzte 30 Tage)", "pws.estimatedCost": "Geschätzte Kosten", "pws.costDisclaimer": "Schätzung basierend auf API-Listenpreisen, keine tatsächliche Abrechnung.", + "pws.unresolvedRequestedModel": "Enthält Nutzung eines nicht aufgelösten angefragten Modells", "pws.modelBreakdown": "Modellaufschlüsselung", "pws.col.model": "Modell", "pws.col.cost": "Gesch. Kosten", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 65902b7385..0b27904e23 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1165,6 +1165,7 @@ export const en = { "pws.usageLast30d": "Usage (last 30 days)", "pws.estimatedCost": "Estimated cost", "pws.costDisclaimer": "API list-price estimate, not an actual charge.", + "pws.unresolvedRequestedModel": "Includes unresolved requested model usage", "pws.modelBreakdown": "Model breakdown", "pws.col.model": "Model", "pws.col.cost": "Est. cost", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index f807c773e2..6c8c44f4ab 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1138,6 +1138,7 @@ export const fr: Record = { "pws.usageLast30d": "Utilisation (30 derniers jours)", "pws.estimatedCost": "Coût estimé", "pws.costDisclaimer": "Estimation fondée sur le tarif public de l’API, et non montant réellement facturé.", + "pws.unresolvedRequestedModel": "Inclut l’utilisation d’un modèle demandé non résolu", "pws.modelBreakdown": "Répartition par modèle", "pws.col.model": "Modèle", "pws.col.cost": "Coût est.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index b197064f5f..1c4f4100c5 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2302,6 +2302,7 @@ export const ja: Record = { "models.tipDisabled": "Disabled", "pws.estimatedCost": "Estimated cost", "pws.costDisclaimer": "API list-price estimate, not an actual charge.", + "pws.unresolvedRequestedModel": "要求モデルを特定せず既定プロバイダーで処理した使用量を含む", "pws.modelBreakdown": "Model breakdown", "pws.col.model": "Model", "pws.col.cost": "Est. cost", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index eda6d51710..f260f00212 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1903,6 +1903,7 @@ export const ko: Record = { "pws.usageLast30d": "사용량 (최근 30일)", "pws.estimatedCost": "추정 비용", "pws.costDisclaimer": "API 공시가 기준 추정치이며, 실제 청구 금액이 아닙니다.", + "pws.unresolvedRequestedModel": "기본 경로 요청 포함 · 실제 모델 미확인", "pws.modelBreakdown": "모델별 사용량", "pws.col.model": "모델", "pws.col.cost": "추정 비용", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 240796d429..742c653c70 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1149,6 +1149,7 @@ export const ru: Record = { "pws.usageLast30d": "Использование (последние 30 дней)", "pws.estimatedCost": "Ориентировочная стоимость", "pws.costDisclaimer": "Оценка на основе публичных цен API, не фактический счёт.", + "pws.unresolvedRequestedModel": "Включает запросы с неразрешённым именем модели", "pws.modelBreakdown": "Разбивка по моделям", "pws.col.model": "Модель", "pws.col.cost": "Ориент. стоимость", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 826ca002cc..b4cc677666 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1156,6 +1156,7 @@ export const tr: Record = { "pws.usageLast30d": "Kullanım (son 30 gün)", "pws.estimatedCost": "Tahmini maliyet", "pws.costDisclaimer": "API liste fiyatı tahminidir.", + "pws.unresolvedRequestedModel": "Çözümlenemeyen istenen model kullanımını içerir", "pws.modelBreakdown": "Model dağılımı", "pws.col.model": "Model", "pws.col.cost": "Tahm. maliyet", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ac7fbca392..9814f8c6cd 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -949,6 +949,7 @@ export const zhTW: Record = { "pws.usageLast30d": "用量(最近 30 天)", "pws.estimatedCost": "預估費用", "pws.costDisclaimer": "基於 API 公示價格的預估值,非實際計費金額。", + "pws.unresolvedRequestedModel": "包含未解析請求模型、由預設供應商處理的用量", "pws.modelBreakdown": "模型用量明細", "pws.col.model": "模型", "pws.col.cost": "預估費用", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 70086143e8..470a0b32d3 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1896,6 +1896,7 @@ export const zh: Record = { "pws.usageLast30d": "用量(最近 30 天)", "pws.estimatedCost": "预估费用", "pws.costDisclaimer": "基于 API 公示价格的预估值,非实际计费金额。", + "pws.unresolvedRequestedModel": "包含未解析请求模型、由默认提供商处理的用量", "pws.modelBreakdown": "模型用量明细", "pws.col.model": "模型", "pws.col.cost": "预估费用", diff --git a/gui/src/provider-workspace/usage.ts b/gui/src/provider-workspace/usage.ts index 5d500204d9..5f7b0cee7e 100644 --- a/gui/src/provider-workspace/usage.ts +++ b/gui/src/provider-workspace/usage.ts @@ -7,6 +7,7 @@ */ import type { WorkspaceSections } from "./catalog"; +import type { ProviderModelUsageRow } from "../components/provider-workspace/types"; /** * Per-provider model count as returned by /api/selected-models. @@ -80,6 +81,23 @@ export interface ProviderUsageTotals { totalTokens?: number; } +/** Keep serving-provider attribution while computing shares within each provider. */ +export function buildProviderModelUsage( + models: readonly (ProviderModelUsageRow & { provider: string })[], + totals: Record, +): Record { + const result: Record = {}; + for (const row of models) { + const providerTokens = totals[row.provider]?.totalTokens ?? 0; + const { provider, ...model } = row; + (result[provider] ??= []).push({ + ...model, + shareRatio: providerTokens > 0 ? Math.min(1, Math.max(0, row.totalTokens / providerTokens)) : 0, + }); + } + return result; +} + export interface MostUsedProvider extends ProviderUsageTotals { name: string; requests: number; diff --git a/gui/src/styles/provider-workspace-shell.css b/gui/src/styles/provider-workspace-shell.css index 04b7c82325..cb5c14b15d 100644 --- a/gui/src/styles/provider-workspace-shell.css +++ b/gui/src/styles/provider-workspace-shell.css @@ -1064,6 +1064,7 @@ .pws-model-table .num { text-align: right; font-variant-numeric: tabular-nums; } .pws-model-table .mono { font-family: var(--font-mono, monospace); font-size: 0.78rem; } .pws-model-row:hover { background: var(--bg-subtle, rgba(128,128,128,0.05)); } +.pws-model-attribution { font-family: var(--font-sans, sans-serif); font-size: 0.75rem; white-space: normal; word-break: keep-all; } .pws-share-bar { height: 6px; background: var(--border-faint, rgba(128,128,128,0.15)); border-radius: 3px; min-width: 60px; } .pws-share-bar-fill { height: 100%; background: var(--green, #22c55e); border-radius: 3px; } .pws-model-detail { background: var(--bg-subtle, rgba(128,128,128,0.05)); } diff --git a/gui/tests/provider-usage-attribution.test.tsx b/gui/tests/provider-usage-attribution.test.tsx new file mode 100644 index 0000000000..9b456fd328 --- /dev/null +++ b/gui/tests/provider-usage-attribution.test.tsx @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import ProviderUsage from "../src/components/provider-workspace/ProviderUsage"; +import { LanguageProvider } from "../src/i18n/provider"; +import { buildProviderModelUsage } from "../src/provider-workspace/usage"; +import type { WorkspaceItem } from "../src/provider-workspace/catalog"; + +const item: WorkspaceItem = { + name: "kimi", adapter: "openai-chat", authMode: "oauth", + baseUrl: "https://api.kimi.com/coding/v1", tier: "accounts", +}; +const base = { requests: 1, inputTokens: 70, outputTokens: 10, totalTokens: 80, shareRatio: 0.008 }; + +test("model grouping preserves serving provider and uses provider-local shares", () => { + const rows = buildProviderModelUsage([ + { ...base, provider: "kimi", model: "anthropic/claude-opus-5", hasUnresolvedRequestedModel: true }, + { ...base, provider: "kimi", model: "k3", totalTokens: 20 }, + { ...base, provider: "anthropic", model: "claude-opus-5", totalTokens: 9900 }, + ], { kimi: { totalTokens: 100 }, anthropic: { totalTokens: 9900 } }); + expect(rows.kimi).toHaveLength(2); + expect(rows.kimi[0]?.shareRatio).toBe(0.8); + expect(rows.kimi[1]?.shareRatio).toBe(0.2); + expect(rows.anthropic).toHaveLength(1); + expect(rows.anthropic[0]?.shareRatio).toBe(1); + expect(rows.kimi[0]?.hasUnresolvedRequestedModel).toBe(true); + expect(rows.anthropic[0]?.hasUnresolvedRequestedModel).toBeUndefined(); +}); + +test("missing or zero provider totals do not produce infinite model shares", () => { + const models = [{ ...base, provider: "kimi", model: "k3" }]; + expect(buildProviderModelUsage(models, {}).kimi[0]?.shareRatio).toBe(0); + expect(buildProviderModelUsage(models, { kimi: { totalTokens: 0 } }).kimi[0]?.shareRatio).toBe(0); +}); + +test("the provider table qualifies unresolved requests without hiding their usage", () => { + const rows = buildProviderModelUsage([ + { ...base, provider: "kimi", model: "policy/does-not-exist", hasUnresolvedRequestedModel: true }, + ], { kimi: { totalTokens: 100 } }); + const markup = renderToStaticMarkup( + + ); + expect(markup).toContain("policy/does-not-exist"); + expect(markup).toContain("Includes unresolved requested model usage"); + expect(markup).toContain("width:80%"); + expect(markup).not.toContain("~$"); +}); diff --git a/src/router.ts b/src/router.ts index 874af4633c..d50f831d4d 100644 --- a/src/router.ts +++ b/src/router.ts @@ -42,10 +42,17 @@ import { type RouteDecisionTraceV1, type TraceCandidateInput, } from "./routing/trace"; -import { getRoutingProfile, resolvePolicyProfileId } from "./routing/profile"; +import { getRoutingProfile, resolvePolicyProfileId, POLICY_NAMESPACE } from "./routing/profile"; import { evaluatePolicyProfile, type PolicyRequestEvidence } from "./routing/evaluator"; import { assemblePolicyCandidateEvidence } from "./routing/compatibility/assemble"; +export class UnknownRoutingPolicyError extends Error { + constructor(readonly profileId: string) { + super(`Unknown routing policy: ${profileId}`); + this.name = "UnknownRoutingPolicyError"; + } +} + export class NoEligiblePolicyCandidateError extends Error { /** Evaluation trace (with per-candidate exclusions) when nothing qualified. */ readonly trace?: RouteDecisionTraceV1; @@ -604,11 +611,13 @@ function routeModelInternal( // configured profile alias executes the policy evaluator and routes the // selected candidate. Only explicit requests reach this branch; concrete // recursive targets skip policy resolution entirely (bypassCombos) so an - // alias matching a selected candidate can never recurse, and a - // `policy/` without a configured profile falls through to normal - // provider/default resolution instead of failing. + // alias matching a selected candidate can never recurse. Missing reserved + // policy selectors fail before ordinary provider/default resolution. const policyId = !bypassCombos ? resolvePolicyProfileId(config, modelId) : null; const profile = policyId ? getRoutingProfile(config, policyId) : undefined; + if (!bypassCombos && !profile && (policyId !== null || modelId.startsWith(`${POLICY_NAMESPACE}/`))) { + throw new UnknownRoutingPolicyError(policyId ?? modelId.slice(POLICY_NAMESPACE.length + 1)); + } if (profile && policyId) { // One clock read per decision keeps candidate evidence, exclusions, and // scores mutually consistent and reproducible. diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index b66f17ccb1..6df2b70cef 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -22,7 +22,7 @@ import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPo import { redactSecretString } from "../lib/redact"; import { resolveClientRetryAfter } from "../lib/retry-after"; import { estimateTokens } from "../lib/token-estimate"; -import { NoEligiblePolicyCandidateError, routeModel } from "../router"; +import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router"; import { evidenceFromBody } from "../routing/request-evidence"; import { resolveWireProtocolOverride } from "./adapter-resolve"; import type { OcxConfig } from "../types"; @@ -146,6 +146,10 @@ async function handleChatCompletionsWithBudget( } if (!effortRow && isNativeChatRouteEligible(route, chatBody)) chatNativeRoute = route; } catch (err) { + if (err instanceof UnknownRoutingPolicyError) { + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" }); + return chatCompletionsErrorResponse(404, err.message, "invalid_request_error"); + } if (err instanceof NoEligiblePolicyCandidateError) { logCtx.routeDecision = err.trace; if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" }); diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 70572f9c67..4be12d932d 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -26,7 +26,7 @@ import { } from "../claude/outbound"; import { clearableDeadline, idleDeadline } from "../lib/abort"; import { estimateTokens } from "../lib/token-estimate"; -import { NoEligiblePolicyCandidateError, routeModel } from "../router"; +import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router"; import { evidenceFromBody } from "../routing/request-evidence"; import { resolveWireProtocolOverride } from "./adapter-resolve"; import type { OcxConfig } from "../types"; @@ -763,6 +763,10 @@ async function handleClaudeMessagesWithBudget( if (ladder !== undefined && ladder.length === 0) delete internalBody.reasoning; } } catch (err) { + if (err instanceof UnknownRoutingPolicyError) { + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" }); + return anthropicErrorResponse(404, err.message, "invalid_request_error"); + } if (err instanceof NoEligiblePolicyCandidateError) { logCtx.routeDecision = err.trace; if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" }); diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index 3ebea685e8..b4423abbca 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -53,6 +53,7 @@ import { drainAndShutdown } from "../lifecycle"; import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; import { estimateComboCost, estimateRequestCost, serviceTierContext, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; import type { PersistedUsageAttempt } from "../../usage/log"; +import { usageModelPriceOptions } from "../../usage/model-identity"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; import { applySystemEnvToggle } from "../system-env"; @@ -93,7 +94,7 @@ export type CostResult = | { kind: "value"; estimate: NonNullable>; estimateReasons: CostEstimateReason[] } | { kind: "unavailable"; reason: MetricUnavailableReason }; -export type MetricSource = Pick & { +export type MetricSource = Pick & { attempts?: readonly PersistedUsageAttempt[]; }; @@ -132,8 +133,8 @@ export function unavailableCostReason(entry: MetricSource): MetricUnavailableRea export function costResult(entry: MetricSource): CostResult { const tier = serviceTierContext(entry); const estimate = entry.attempts?.length - ? estimateComboCost(entry.attempts, undefined, tier) - : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier }); + ? estimateComboCost(entry.attempts.map(attempt => ({ ...attempt, ...usageModelPriceOptions(entry, attempt) })), undefined, tier) + : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier, ...usageModelPriceOptions(entry, entry) }); if (!estimate) return { kind: "unavailable", reason: unavailableCostReason(entry) }; const estimateReasons = [ entry.usageStatus === "estimated" || entry.usage?.estimated ? "usage_estimated" as const : undefined, @@ -162,7 +163,7 @@ export function requestLogDto(entry: RequestLogEntry): Record { ...attempt, displayMetrics: { tokPerSecond: tokPerSecondResult(attempt), - cost: costResult({ ...attempt, attempts: undefined, requestedServiceTier: entry.requestedServiceTier, configuredServiceTier: entry.configuredServiceTier, responseServiceTier: entry.responseServiceTier }), + cost: costResult({ ...attempt, attempts: undefined, routeDecision: entry.routeDecision, requestedServiceTier: entry.requestedServiceTier, configuredServiceTier: entry.configuredServiceTier, responseServiceTier: entry.responseServiceTier }), }, })), } diff --git a/src/usage/cost.ts b/src/usage/cost.ts index 5f07401919..f7004634c9 100644 --- a/src/usage/cost.ts +++ b/src/usage/cost.ts @@ -35,6 +35,13 @@ import { /** Published long-context pricing band (#908). */ export type ContextTierName = "long"; +export interface PriceResolutionOptions { + /** False for unresolved slash selectors; exact provider/user rates still apply. */ + allowModelLevelFallback?: boolean; +} + +type AttemptCostInput = Pick & PriceResolutionOptions; + /** * Service-tier provenance. `effectiveServiceTier()` collapses these with `??`, * but provider-specific long-context rules need to know WHICH source supplied Fast. @@ -178,6 +185,7 @@ export function resolveMatchedPrice( modelId: string, overlays: readonly ExpectedPriceOverlay[] = EXPECTED_PRICE_OVERLAYS, userOverlays: readonly ExpectedPriceOverlay[] = activeUserCostOverlays(), + options: PriceResolutionOptions = {}, ): MatchedPrice | null { // User-configured overlays are keyed by the EXACT configured provider name. // A provider that literally exists in config.providers keeps its own pricing @@ -201,14 +209,14 @@ export function resolveMatchedPrice( // user overlays get a NEW array identity + version bump on every config refresh, // so memoized rows never go stale. if (overlays === EXPECTED_PRICE_OVERLAYS && userOverlays === activeUserCostOverlays()) { - const cacheKey = `${userCostOverlayVersion()} ${provider} ${modelId}`; + const cacheKey = `${userCostOverlayVersion()} ${options.allowModelLevelFallback !== false} ${provider} ${modelId}`; if (!priceMemo.has(cacheKey)) { if (priceMemo.size >= 512) priceMemo.clear(); - priceMemo.set(cacheKey, resolveMatchedPriceInner(provider, modelId, overlays, userOverlays)); + priceMemo.set(cacheKey, resolveMatchedPriceInner(provider, modelId, overlays, userOverlays, options)); } return priceMemo.get(cacheKey)!; } - return resolveMatchedPriceInner(provider, modelId, overlays, userOverlays); + return resolveMatchedPriceInner(provider, modelId, overlays, userOverlays, options); } const priceMemo = new Map(); @@ -223,14 +231,15 @@ function resolveMatchedPriceInner( modelId: string, overlays: readonly ExpectedPriceOverlay[], userOverlays: readonly ExpectedPriceOverlay[], + options: PriceResolutionOptions, ): MatchedPrice | null { - const direct = resolveMatchedPriceExact(provider, modelId, overlays, userOverlays); + const direct = resolveMatchedPriceExact(provider, modelId, overlays, userOverlays, options); if (direct) return direct; // Antigravity historical/wire ids often lack an exact overlay; fall back to the // picker/call base model so collapsed usage rows still get a price. if (provider === "google-antigravity" || provider.startsWith("google-antigravity")) { const base = canonicalAntigravityUsageModel(modelId); - if (base !== modelId) return resolveMatchedPriceExact(provider, base, overlays, userOverlays); + if (base !== modelId) return resolveMatchedPriceExact(provider, base, overlays, userOverlays, options); } return null; } @@ -245,6 +254,7 @@ function resolveMatchedPriceExact( modelId: string, overlays: readonly ExpectedPriceOverlay[], userOverlays: readonly ExpectedPriceOverlay[], + options: PriceResolutionOptions, ): MatchedPrice | null { // User-configured provider overlay wins over every compiled catalog: the // operator's explicit price is authoritative for the ~$ estimate. @@ -280,7 +290,7 @@ function resolveMatchedPriceExact( } const overlay = findExpectedPriceOverlay(provider, modelId, overlays); if (!overlay || !validCost4(overlay.cost4) || !hasNonZeroCost(overlay.cost4)) { - return resolveModelLevelPrice(provider, modelId); + return options.allowModelLevelFallback === false ? null : resolveModelLevelPrice(provider, modelId); } if (overlay.status === "unverified") return null; return { @@ -526,7 +536,7 @@ function isOpenRouterPriorityLowerBound( * missing so combos can fail closed. */ export function estimateAttemptCost( - attempt: Pick, + attempt: AttemptCostInput, overlays: readonly ExpectedPriceOverlay[] = EXPECTED_PRICE_OVERLAYS, serviceTier?: ServiceTierInput, userOverlays: readonly ExpectedPriceOverlay[] = activeUserCostOverlays(), @@ -534,7 +544,7 @@ export function estimateAttemptCost( if (!attempt.usage) return null; const tokens = normalizeCostTokens(attempt.usage); if (!tokens) return null; - const price = resolveMatchedPrice(attempt.provider, attempt.model, overlays, userOverlays); + const price = resolveMatchedPrice(attempt.provider, attempt.model, overlays, userOverlays, attempt); if (!price) return null; const attemptServiceTier = attempt.tierOutcome ? serviceTierContextFromOutcome(attempt.tierOutcome) @@ -566,7 +576,7 @@ export function estimateAttemptCost( * attempt is unpriced or unnormalizable, return null rather than a partial sum. */ export function estimateComboCost( - attempts: readonly Pick[], + attempts: readonly AttemptCostInput[], overlays: readonly ExpectedPriceOverlay[] = EXPECTED_PRICE_OVERLAYS, serviceTier?: ServiceTierInput, userOverlays: readonly ExpectedPriceOverlay[] = activeUserCostOverlays(), @@ -614,6 +624,7 @@ export function estimateRequestCost( usage?: OcxUsage; usageStatus: UsageStatus; serviceTier?: ServiceTierInput; + allowModelLevelFallback?: boolean; }, overlays: readonly ExpectedPriceOverlay[] = EXPECTED_PRICE_OVERLAYS, userOverlays: readonly ExpectedPriceOverlay[] = activeUserCostOverlays(), @@ -621,7 +632,7 @@ export function estimateRequestCost( if (!input.usage) return null; const tokens = normalizeCostTokens(input.usage); if (!tokens) return null; - const price = resolveMatchedPrice(input.provider, input.model, overlays, userOverlays); + const price = resolveMatchedPrice(input.provider, input.model, overlays, userOverlays, input); if (!price) return null; const [tieredCost4, contextTier, contextPriorityLowerBound] = applyContextTier( price.cost4, input.provider, input.model, input.usage.inputTokens, input.serviceTier, diff --git a/src/usage/model-identity.ts b/src/usage/model-identity.ts new file mode 100644 index 0000000000..cfd8ab3f28 --- /dev/null +++ b/src/usage/model-identity.ts @@ -0,0 +1,26 @@ +import { baseProviderLabel } from "../providers/label"; +import type { PriceResolutionOptions } from "./cost"; +import type { PersistedUsageEntry } from "./log"; + +type TraceSource = Pick; +type ModelTarget = Pick; + +/** Saved routing provenance, not a claim about the model a remote endpoint ran. */ +export function isUnresolvedRequestedModel(source: TraceSource, target: ModelTarget): boolean { + const trace = source.routeDecision; + return trace !== undefined + && trace.routeKind === "default-provider" + && trace.selected.reason === "default-provider" + && trace.truncated?.strings !== true + && trace.selected.model.length > 0 + && trace.requestedModel === trace.selected.model + && target.model === trace.selected.model + && baseProviderLabel(target.provider) === baseProviderLabel(trace.selected.provider); +} + +/** Bare fallback rates remain eligible; unresolved slash IDs need a provider-specific rate. */ +export function usageModelPriceOptions(source: TraceSource, target: ModelTarget): PriceResolutionOptions { + return { + allowModelLevelFallback: !target.model.includes("/") || !isUnresolvedRequestedModel(source, target), + }; +} diff --git a/src/usage/summary.ts b/src/usage/summary.ts index 53148331f8..6390db38c1 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -1,6 +1,7 @@ import { baseProviderLabel } from "../providers/label"; import { canonicalAntigravityUsageModel } from "../providers/antigravity-models"; import { usageDisplayTotalTokens } from "./totals"; +import { isUnresolvedRequestedModel, usageModelPriceOptions } from "./model-identity"; import { isCodexUsageAccountLogLabel, type PersistedUsageEntry, type UsageStatus } from "./log"; import { type AttemptCostEstimate, type CostEstimate, estimateAttemptCost, estimateRequestCost, serviceTierContext, type ServiceTierContext } from "./cost"; @@ -59,6 +60,8 @@ export interface UsageDay { export interface UsageDayModel { model: string; provider: string; + /** Includes trace-proven unresolved requested selectors; absence is not confirmation. */ + hasUnresolvedRequestedModel?: true; requests: number; attemptCount: number; totalTokens: number; @@ -73,6 +76,8 @@ export interface UsageDayModel { export interface UsageModel { provider: string; model: string; + /** Includes trace-proven unresolved requested selectors; absence is not confirmation. */ + hasUnresolvedRequestedModel?: true; resolvedModel?: string; requests: number; attemptCount: number; @@ -208,7 +213,7 @@ export function computeEntryCost(entry: PersistedUsageEntry): EntryCostInfo { const tier = serviceTierContext(entry); if (entry.attempts?.length) { const attemptEstimates = entry.attempts.map(attempt => - estimateAttemptCost(attempt, undefined, tier) + estimateAttemptCost({ ...attempt, ...usageModelPriceOptions(entry, attempt) }, undefined, tier) ); let costTotal = 0; let isPriced = false; @@ -221,6 +226,7 @@ export function computeEntryCost(entry: PersistedUsageEntry): EntryCostInfo { return { tier, estimate: null, attemptEstimates, costTotal, isPriced }; } const estimate = estimateRequestCost({ + ...usageModelPriceOptions(entry, entry), provider: entry.provider, model: entry.model, usage: entry.usage, @@ -324,6 +330,7 @@ interface UsageAttribution { provider: string; model: string; resolvedModel?: string; + hasUnresolvedRequestedModel?: true; accountLogLabel?: string; usageStatus: UsageStatus; usage?: PersistedUsageEntry["usage"]; @@ -366,6 +373,7 @@ function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[] { requestId: entry.requestId, provider: entry.provider, ...usageModelIdentity(entry.provider, entry.model, entry.resolvedModel), + ...(isUnresolvedRequestedModel(entry, entry) ? { hasUnresolvedRequestedModel: true as const } : {}), ...(entry.accountLogLabel ? { accountLogLabel: entry.accountLogLabel } : {}), usageStatus: entry.usageStatus, ...(entry.usage ? { usage: entry.usage } : {}), @@ -376,6 +384,7 @@ function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[] { requestId: entry.requestId, provider: attempt.provider, ...usageModelIdentity(attempt.provider, attempt.model), + ...(isUnresolvedRequestedModel(entry, attempt) ? { hasUnresolvedRequestedModel: true as const } : {}), ...(attempt.accountLogLabel ? { accountLogLabel: attempt.accountLogLabel } : {}), usageStatus: attempt.usageStatus, ...(attempt.usage ? { usage: attempt.usage } : {}), @@ -517,6 +526,7 @@ interface UsageModelAccumulator { provider: string; model: string; resolvedModel?: string; + hasUnresolvedRequestedModel?: true; firstSeen: number; attemptCount: number; dayTotalTokens: number; @@ -704,6 +714,7 @@ function cloneModelAccumulator(source: UsageModelAccumulator): UsageModelAccumul } function mergeModelAccumulator(target: UsageModelAccumulator, source: UsageModelAccumulator): void { + if (source.hasUnresolvedRequestedModel) target.hasUnresolvedRequestedModel = true; if (source.firstSeen < target.firstSeen) { target.firstSeen = source.firstSeen; target.resolvedModel = source.resolvedModel; @@ -874,6 +885,7 @@ function buildDayModels( return retainedModelAccumulators(sorted, overlaps).map(model => ({ model: model.model, provider: model.provider, + ...(model.hasUnresolvedRequestedModel ? { hasUnresolvedRequestedModel: true as const } : {}), requests: requestCountsFor(model).requests, attemptCount: model.attemptCount, totalTokens: model.dayTotalTokens, @@ -900,6 +912,7 @@ function buildUsageModels( return { provider: model.provider, model: model.model, + ...(model.hasUnresolvedRequestedModel ? { hasUnresolvedRequestedModel: true as const } : {}), ...(model.resolvedModel ? { resolvedModel: model.resolvedModel } : {}), requests, attemptCount: model.attemptCount, @@ -1115,6 +1128,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator { attribution: UsageAttribution, estimate: AttemptCostEstimate | CostEstimate | null, ): void { + if (attribution.hasUnresolvedRequestedModel) breakdown.hasUnresolvedRequestedModel = true; breakdown.attemptCount += 1; if (attribution.usage) { breakdown.inputTokens += attribution.usage.inputTokens; diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 6bfa70aaa5..a26703f59c 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -364,6 +364,15 @@ estimated` split exists for, and why coverage is reported alongside totals. The main Dashboard surfaces a 30d token / coverage summary. The in-memory `requestLog` is capped at 200 entries and is **not** the source of truth for aggregation — the JSONL on disk is. +Usage aggregation does not infer confirmed model identity merely from a requested selector. +Model rows with saved unchanged +default-provider route evidence carry `hasUnresolvedRequestedModel`: their tokens stay under +the recorded serving provider, with an unresolved-request annotation. For those slash-containing +selectors, a vendor-only inferred price is unavailable; exact provider and user prices remain +eligible. Missing trace evidence is not reconstructed from today's configuration. Provider-detail +model shares use that provider's token total, not the global total. Unknown reserved `policy/` +selectors are rejected before upstream dispatch; historical rows remain unchanged. + The management API retains the compact accumulator plus bounded query summaries; it never retains normalized per-request rows after a response. File identity changes, shrinkage, same-size metadata changes, pricing-overlay changes, and local-time-zone changes force a cold rebuild. Ordinary growth diff --git a/tests/routing/policy-execution.test.ts b/tests/routing/policy-execution.test.ts index a286c8c15d..96b6405a5b 100644 --- a/tests/routing/policy-execution.test.ts +++ b/tests/routing/policy-execution.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { NoEligiblePolicyCandidateError, routeModel } from "../../src/router"; +import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeCompactionModel, routeConcreteModel, routeModel } from "../../src/router"; import { isValidProviderName } from "../../src/config"; import { getRoutingProfile } from "../../src/routing/profile"; import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; @@ -234,13 +234,15 @@ describe("policy execution (RI-05)", () => { expect(() => routeModel(config, "policy/tools", { toolsRequired: true })).toThrow(NoEligiblePolicyCandidateError); }); - test("unresolved policy/ falls through to normal resolution", () => { + test("missing and empty reserved policies reject before default routing", () => { const config = baseConfig(); - // No profile named "nope": the reserved-looking id must not throw and not - // shadow provider/default resolution. - const route = routeModel(config, "policy/nope"); - expect(route.routeKind).toBe("default-provider"); - expect(route.providerName).toBe("a"); + for (const selector of ["policy/nope", "policy/", "policy/constructor"]) { + expect(() => routeModel(config, selector)).toThrow(UnknownRoutingPolicyError); + expect(() => routeCompactionModel(config, selector)).toThrow(UnknownRoutingPolicyError); + } + expect(routeModel(config, "vendor/native-model")).toMatchObject({ routeKind: "default-provider", providerName: "a", modelId: "vendor/native-model" }); + expect(routeModel(config, "unknown-bare")).toMatchObject({ routeKind: "default-provider", modelId: "unknown-bare" }); + expect(routeConcreteModel(config, "a/policy/nope")).toMatchObject({ providerName: "a", modelId: "policy/nope" }); }); test("policy selection is deterministic across calls", () => { diff --git a/tests/routing/routing-policy-surface-parity.test.ts b/tests/routing/routing-policy-surface-parity.test.ts index 71e4b136bd..810c326a66 100644 --- a/tests/routing/routing-policy-surface-parity.test.ts +++ b/tests/routing/routing-policy-surface-parity.test.ts @@ -117,7 +117,7 @@ mock.module("../../src/server/adapter-resolve", () => ({ }, })); -const { handleResponses } = await import("../../src/server/responses"); +const { handleResponses, handleResponsesCompact } = await import("../../src/server/responses"); const { handleChatCompletions } = await import("../../src/server/chat-completions"); const { handleClaudeMessages } = await import("../../src/server/claude-messages"); @@ -162,6 +162,35 @@ function minimalSuccessAdapter(provider: OcxProviderConfig): ProviderAdapter { } describe("routing policy request evidence parity (via dev handlers)", () => { + test("missing and empty policies return compatible 404s on every wire before adapter resolution", async () => { + let adapterCalls = 0; + adapterFactory = provider => { + adapterCalls += 1; + return minimalSuccessAdapter(provider); + }; + for (const model of ["policy/missing", "policy/"]) { + for (const stream of [false, true]) { + const bodies = [ + { path: "/v1/responses", handler: handleResponses, body: { model, stream, input: "hello" } }, + { path: "/v1/chat/completions", handler: handleChatCompletions, body: { model, stream, messages: [{ role: "user", content: "hello" }] } }, + { path: "/v1/messages", handler: handleClaudeMessages, body: { model, stream, max_tokens: 64, messages: [{ role: "user", content: "hello" }] } }, + { path: "/v1/responses/compact", handler: handleResponsesCompact, body: { model, stream, input: "hello" } }, + ]; + for (const { path, handler, body } of bodies) { + const log: RequestLogContext = { model: "", provider: "" }; + const response = await handler(new Request(`http://localhost${path}`, { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }), testConfig(), log); + expect(response.status).toBe(404); + const payload = await response.json() as { error: { type: string; message: string } }; + expect(payload.error.type).toBe("invalid_request_error"); + expect(payload.error.message).toStartWith("Unknown routing policy:"); + expect(log.routeDecision).toBeUndefined(); + expect(adapterCalls).toBe(0); + } + } + } + }); test("rich evidence (tools + image) produces identical route decision across all three surfaces", async () => { adapterFactory = minimalSuccessAdapter; const config = testConfig(); diff --git a/tests/server/management-api-logs-metrics.test.ts b/tests/server/management-api-logs-metrics.test.ts index a9fa2821b8..76fbd8024e 100644 --- a/tests/server/management-api-logs-metrics.test.ts +++ b/tests/server/management-api-logs-metrics.test.ts @@ -11,6 +11,8 @@ import { type RequestLogEntry, } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; +import { buildRouteDecisionTrace } from "../../src/routing/trace"; +import { summarizeUsage } from "../../src/usage/summary"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const config = { providers: [] } as unknown as OcxConfig; @@ -58,6 +60,67 @@ function baseEntry(overrides: Partial): RequestLogEntry { } describe("GET /api/logs display metrics", () => { + test("parent, individual attempt DTO and summary agree on unresolved slash cost without rewriting history", async () => { + const model = "anthropic/claude-3-haiku-20240307"; + const row = baseEntry({ + requestId: "unresolved", provider: "kimi", model, + usage: { inputTokens: 100, outputTokens: 10 }, totalTokens: 110, + routeDecision: buildRouteDecisionTrace({ requestedModel: model, routeKind: "default-provider", selected: { provider: "kimi", model, reason: "default-provider" } }), + attempts: [{ + ordinal: 1, provider: "kimi", model, adapter: "openai-chat", status: 200, durationMs: 1000, + sendCount: 1, recoveryKinds: [], usageStatus: "reported", usage: { inputTokens: 100, outputTokens: 10 }, totalTokens: 110, + }], + }); + addRequestLog(row); + const ledgerBefore = readFileSync(usageLogPath(), "utf8"); + const [dto] = await readLogs(); + expect(dto!.displayMetrics.cost).toEqual({ kind: "unavailable", reason: "combo_attempt_unavailable" }); + expect(dto!.attempts[0].displayMetrics.cost).toEqual({ kind: "unavailable", reason: "price_unmatched" }); + expect(dto!.attempts[0].displayMetrics.tokPerSecond.kind).toBe("value"); + const summary = summarizeUsage([{ ...row, accountLogLabel: undefined }], "all", Date.now()); + expect(summary.models[0]).toMatchObject({ provider: "kimi", model, totalTokens: 110, hasUnresolvedRequestedModel: true, unpricedRequests: 1 }); + expect(summary.models[0]?.estimatedCostUsd).toBeUndefined(); + expect(readFileSync(usageLogPath(), "utf8")).toBe(ledgerBefore); + expect(getRequestLogEntries()[0]?.attempts?.[0]).not.toHaveProperty("allowModelLevelFallback"); + expect(getRequestLogEntries()[0]?.attempts?.[0]).not.toHaveProperty("displayMetrics"); + }); + + test("bare fallback annotation keeps parent and attempt pricing; another attempt is not restricted by parent trace", async () => { + const model = "claude-3-haiku-20240307"; + const row = baseEntry({ + requestId: "bare-fallback", provider: "kimi", model, + usage: { inputTokens: 100, outputTokens: 10 }, + routeDecision: buildRouteDecisionTrace({ requestedModel: model, routeKind: "default-provider", selected: { provider: "kimi", model, reason: "default-provider" } }), + attempts: [{ + ordinal: 1, provider: "kimi", model, adapter: "openai-chat", status: 200, durationMs: 1000, + sendCount: 1, recoveryKinds: [], usageStatus: "reported", usage: { inputTokens: 100, outputTokens: 10 }, + }], + }); + addRequestLog(row); + const [dto] = await readLogs(); + expect(dto!.displayMetrics.cost.kind).toBe("value"); + expect(dto!.attempts[0].displayMetrics.cost.kind).toBe("value"); + expect(summarizeUsage([{ ...row, accountLogLabel: undefined }], "all", Date.now()).models[0]).toMatchObject({ hasUnresolvedRequestedModel: true, pricedRequests: 1 }); + clearRequestLogsForTests(); + const selector = `anthropic/${model}`; + addRequestLog({ ...row, requestId: "retargeted", routeDecision: buildRouteDecisionTrace({ + requestedModel: selector, routeKind: "default-provider", selected: { provider: "kimi", model: selector, reason: "default-provider" }, + }), attempts: row.attempts!.map(attempt => ({ ...attempt, provider: "fixture-aggregator", model: selector })) }); + const [retargeted] = await readLogs(); + expect(retargeted!.displayMetrics.cost.kind).toBe("value"); + expect(retargeted!.attempts[0].displayMetrics.cost.kind).toBe("value"); + }); + + test("parent-only unresolved slash cost agrees with summary", async () => { + const model = "anthropic/claude-3-haiku-20240307"; + const row = baseEntry({ provider: "kimi", model, usage: { inputTokens: 100, outputTokens: 10 }, + routeDecision: buildRouteDecisionTrace({ requestedModel: model, routeKind: "default-provider", selected: { provider: "kimi", model, reason: "default-provider" } }), + }); + addRequestLog(row); + const [dto] = await readLogs(); + expect(dto!.displayMetrics.cost).toEqual({ kind: "unavailable", reason: "price_unmatched" }); + expect(summarizeUsage([{ ...row, accountLogLabel: undefined }], "all", Date.now()).summary.unpricedRequests).toBe(1); + }); test("reports filtered total before limit pagination", async () => { addRequestLog(baseEntry({ requestId: "ok-a", provider: "anthropic", status: 200 })); addRequestLog(baseEntry({ requestId: "ok-b", provider: "anthropic", status: 200 })); diff --git a/tests/usage/usage-aggregate-cache.test.ts b/tests/usage/usage-aggregate-cache.test.ts index a4d23fa435..c739d0546d 100644 --- a/tests/usage/usage-aggregate-cache.test.ts +++ b/tests/usage/usage-aggregate-cache.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -22,6 +22,7 @@ import type { OcxConfig } from "../../src/types/config"; import { resetUsageReadCacheForTests, type PersistedUsageEntry } from "../../src/usage/log"; import * as usageLedgerScannerModule from "../../src/usage/ledger-scanner"; import { refreshUserCostOverlays } from "../../src/usage/user-cost-overlays"; +import { buildRouteDecisionTrace } from "../../src/routing/trace"; const NOW = Date.parse("2026-09-01T10:00:00.000Z"); @@ -71,6 +72,29 @@ afterEach(() => { }); describe("retained usage aggregate cache", () => { + test("append and rebuild preserve unresolved attribution and restricted pricing without ledger changes", async () => { + const path = join(testDir, "usage.jsonl"); + writeFileSync(path, line("ordinary")); + await getUsageAggregate(); + const model = "anthropic/claude-3-haiku-20240307"; + const fallback = { ...entry("fallback"), provider: "kimi", model, + routeDecision: buildRouteDecisionTrace({ requestedModel: model, routeKind: "default-provider", selected: { provider: "kimi", model, reason: "default-provider" } }), + }; + appendFileSync(path, `${JSON.stringify(fallback)}\n`); + const before = readFileSync(path, "utf8"); + const appended = await getUsageAggregate(); + expect(appended.update).toBe("append"); + const summary = appended.accumulator.summarize("all", NOW); + expect(summary.summary).toMatchObject({ requests: 2, totalTokens: 4 }); + expect(summary.models.find(row => row.provider === "kimi")).toMatchObject({ model, hasUnresolvedRequestedModel: true, unpricedRequests: 1 }); + expect(summary.models.find(row => row.provider === "kimi")?.estimatedCostUsd).toBeUndefined(); + const filtered = (await getFilteredUsageAggregate({ provider: "kimi" })).accumulator.summarize("all", NOW); + expect(filtered.models[0]).toMatchObject({ hasUnresolvedRequestedModel: true, totalTokens: 2 }); + resetUsageAggregateCacheForTests(); + const rebuilt = (await getUsageAggregate()).accumulator.summarize("all", NOW); + expect(rebuilt).toEqual(summary); + expect(readFileSync(path, "utf8")).toBe(before); + }); test("settled filtered callers reuse a bounded retained aggregate", async () => { writeFileSync(join(testDir, "usage.jsonl"), `${line("one")}${line("two")}`); const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; diff --git a/tests/usage/usage-cost.test.ts b/tests/usage/usage-cost.test.ts index 99685e16d2..387ed3c01d 100644 --- a/tests/usage/usage-cost.test.ts +++ b/tests/usage/usage-cost.test.ts @@ -1328,6 +1328,28 @@ describe("provider cost overlay (user-configured)", () => { }); describe("aggregator vendor-prefixed model ids (#3136)", () => { + test("restricted resolution partitions memoization and only removes vendor fallback", () => { + const model = "anthropic/claude-3-haiku-20240307"; + const restricted = { allowModelLevelFallback: false }; + expect(resolveMatchedPrice("kimi", model)).not.toBeNull(); + expect(resolveMatchedPrice("kimi", model, undefined, undefined, restricted)).toBeNull(); + expect(resolveMatchedPrice("kimi", model)).not.toBeNull(); + // Reverse the order with another provider to catch a cached restricted miss. + expect(resolveMatchedPrice("fixture-aggregator", model, undefined, undefined, restricted)).toBeNull(); + expect(resolveMatchedPrice("fixture-aggregator", model)).not.toBeNull(); + expect(resolveMatchedPrice("openrouter", "anthropic/claude-3.5-sonnet", undefined, undefined, restricted)?.source).toBe("jawcode"); + const overlay: ExpectedPriceOverlay = { + provider: "kimi", modelId: model, cost4: { input: 3, output: 7, cacheRead: 0, cacheWrite: 0 }, + source: "fixture", verifiedAt: "2026-09-05", status: "verified", + }; + expect(resolveMatchedPrice("kimi", model, [overlay], [], restricted)).toMatchObject({ source: "expected", cost4: overlay.cost4 }); + expect(resolveMatchedPrice("kimi", model, [], [overlay], restricted)).toMatchObject({ source: "user", cost4: overlay.cost4 }); + const input = { provider: "kimi", model, usageStatus: "reported" as const, usage: { inputTokens: 100, outputTokens: 10 }, ...restricted }; + expect(estimateRequestCost(input)).toBeNull(); + expect(estimateAttemptCost({ ...input, ordinal: 1 })).toBeNull(); + expect(estimateComboCost([{ ...input, ordinal: 1 }])).toBeNull(); + expect(estimateRequestCost(input, [], [overlay])?.price?.source).toBe("user"); + }); // CommandCode serves "deepseek/deepseek-v4-flash"; the cost catalog stores the bare id. // The exact lookup missed a price that is present, so every request through such a // provider reported no cost at all. diff --git a/tests/usage/usage-summary.test.ts b/tests/usage/usage-summary.test.ts index e701b36b9e..6e26fb2e66 100644 --- a/tests/usage/usage-summary.test.ts +++ b/tests/usage/usage-summary.test.ts @@ -11,6 +11,8 @@ import { summarizeUsage, } from "../../src/usage/summary"; import type { PersistedUsageEntry } from "../../src/usage/log"; +import { buildRouteDecisionTrace } from "../../src/routing/trace"; +import { isUnresolvedRequestedModel } from "../../src/usage/model-identity"; const FIXED_NOW = Date.UTC(2026, 5, 28, 12, 0, 0); @@ -31,9 +33,121 @@ function entry(overrides: Partial & { ts: number }): Persis ...(rest.totalTokens !== undefined ? { totalTokens: rest.totalTokens } : {}), ...(rest.attempts ? { attempts: rest.attempts } : {}), ...(rest.apiKeyId !== undefined ? { apiKeyId: rest.apiKeyId } : {}), + ...(rest.routeDecision ? { routeDecision: rest.routeDecision } : {}), }; } +describe("unresolved requested model attribution", () => { + function fallback(model: string, overrides: Partial = {}): PersistedUsageEntry { + return entry({ + ts: FIXED_NOW - 1, provider: "kimi", model, usageStatus: "reported", + usage: { inputTokens: 30, outputTokens: 8 }, totalTokens: 38, + routeDecision: buildRouteDecisionTrace({ + requestedModel: model, routeKind: "default-provider", + selected: { provider: "kimi", model, reason: "default-provider" }, + }), + ...overrides, + }); + } + + test("preserves slash and bare selectors, provider and tokens without inventing actual models", () => { + for (const model of ["anthropic/claude-opus-5", "cursor/grok-4.5", "policy/does-not-exist", "gemini-3.5-pro", "qwen3.8-max"]) { + const row = fallback(model); + const before = JSON.stringify(row); + const summary = summarizeUsage([row], "all", FIXED_NOW); + expect(summary.summary).toMatchObject({ requests: 1, attemptCount: 1, totalTokens: 38 }); + expect(summary.providers[0]).toMatchObject({ provider: "kimi", requests: 1, totalTokens: 38 }); + expect(summary.models[0]).toMatchObject({ provider: "kimi", model, totalTokens: 38, hasUnresolvedRequestedModel: true }); + expect(summary.days.flatMap(day => day.models)[0]).toMatchObject({ model, totalTokens: 38, hasUnresolvedRequestedModel: true }); + expect(summary.models[0]?.resolvedModel).toBeUndefined(); + expect(JSON.stringify(row)).toBe(before); + } + }); + + test("bare fallback remains priced, slash fallback loses only inferred vendor price", () => { + const bare = summarizeUsage([fallback("claude-3-haiku-20240307")], "all", FIXED_NOW); + expect(bare.models[0]?.hasUnresolvedRequestedModel).toBe(true); + expect(bare.models[0]?.estimatedCostUsd).toBeGreaterThan(0); + const slash = summarizeUsage([fallback("anthropic/claude-3-haiku-20240307")], "all", FIXED_NOW); + expect(slash.models[0]?.hasUnresolvedRequestedModel).toBe(true); + expect(slash.models[0]?.estimatedCostUsd).toBeUndefined(); + expect(slash.summary).toMatchObject({ totalTokens: 38, pricedRequests: 0, unpricedRequests: 1 }); + }); + + test("requires matching complete trace, not resolvedModel or current model-name guesses", () => { + const row = fallback("anthropic/claude-3-haiku-20240307"); + const trace = row.routeDecision!; + const nonEvidence: PersistedUsageEntry[] = [ + { ...row, routeDecision: undefined, resolvedModel: "kimi-k2.5" }, + { ...row, routeDecision: { ...trace, truncated: { strings: true } } }, + { ...row, routeDecision: { ...trace, routeKind: "explicit-provider" } }, + { ...row, routeDecision: { ...trace, requestedModel: "different" } }, + { ...row, routeDecision: { ...trace, selected: { ...trace.selected, reason: "blocked-model-redirect" } } }, + { ...row, provider: "other" }, + { ...row, model: "different" }, + ]; + for (const candidate of nonEvidence) { + expect(isUnresolvedRequestedModel(candidate, candidate)).toBe(false); + expect(summarizeUsage([candidate], "all", FIXED_NOW).models[0]?.hasUnresolvedRequestedModel).toBeUndefined(); + } + expect(isUnresolvedRequestedModel(row, { ...row, provider: "kimi-pabcdef" })).toBe(true); + const echoed = { ...row, resolvedModel: "echo" }; + expect(isUnresolvedRequestedModel(echoed, row)).toBe(true); + }); + + test("matches each attempt and preserves partial-cost and filtered attribution", () => { + const row = fallback("anthropic/claude-3-haiku-20240307"); + const attempt = { + ordinal: 1, provider: row.provider, model: row.model, adapter: "openai-chat", status: 200, + durationMs: 1, sendCount: 1, recoveryKinds: [], usageStatus: "reported" as const, + usage: { inputTokens: 15, outputTokens: 4 }, totalTokens: 19, + }; + row.attempts = [attempt, { ...attempt, ordinal: 2, provider: "anthropic", model: "claude-3-haiku-20240307" }]; + const before = JSON.stringify(row); + const summary = summarizeUsage([row], "all", FIXED_NOW); + expect(summary.summary).toMatchObject({ requests: 1, attemptCount: 2, totalTokens: 38, pricedRequests: 1 }); + expect(summary.models.find(model => model.provider === "kimi")).toMatchObject({ totalTokens: 19, hasUnresolvedRequestedModel: true, unpricedRequests: 1 }); + expect(summary.models.find(model => model.provider === "anthropic")?.hasUnresolvedRequestedModel).toBeUndefined(); + expect(summary.models.find(model => model.provider === "anthropic")?.estimatedCostUsd).toBeGreaterThan(0); + const filtered = createUsageSummaryAccumulator({ filter: { provider: "kimi" } }); + filtered.add(row); + expect(filtered.summarize("all", FIXED_NOW).summary).toMatchObject({ totalTokens: 19, pricedRequests: 0, unpricedRequests: 1 }); + expect(filtered.summarize("all", FIXED_NOW).models[0]?.hasUnresolvedRequestedModel).toBe(true); + expect(JSON.stringify(row)).toBe(before); + }); + + test("mixed rows retain marker across insertion order, day partitions and cloned accumulation", () => { + const marked = fallback("qwen3.8-max"); + const ordinary = { ...marked, requestId: "ordinary", timestamp: FIXED_NOW - 86_400_000, routeDecision: undefined }; + for (const rows of [[marked, ordinary], [ordinary, marked]]) { + const accumulator = createUsageSummaryAccumulator(); + accumulator.add(rows[0]!); + const cloned = accumulator.clone(); + cloned.add(rows[1]!); + const summary = cloned.summarize("all", FIXED_NOW); + expect(summary.models).toHaveLength(1); + expect(summary.models[0]).toMatchObject({ requests: 2, totalTokens: 76, hasUnresolvedRequestedModel: true }); + expect(summary.days.flatMap(day => day.models).filter(model => model.hasUnresolvedRequestedModel)).toHaveLength(1); + } + const sameDay = { ...ordinary, timestamp: marked.timestamp }; + for (const rows of [[marked, sameDay], [sameDay, marked]]) { + const summary = summarizeUsage(rows, "all", FIXED_NOW); + expect(summary.models[0]).toMatchObject({ requests: 2, totalTokens: 76, hasUnresolvedRequestedModel: true }); + expect(summary.days.flatMap(day => day.models)[0]?.hasUnresolvedRequestedModel).toBe(true); + } + }); + + test("overflow retains includes semantics and all tokens", () => { + const rows = Array.from({ length: MAX_USAGE_MODEL_BREAKDOWN_ROWS }, (_, index) => + fallback(`ordinary-${index}`, { requestId: `ordinary-${index}`, routeDecision: undefined })); + rows.push(fallback("qwen3.8-max", { requestId: "overflow" })); + const summary = summarizeUsage(rows, "all", FIXED_NOW); + expect(summary.models.at(-1)).toMatchObject({ provider: "other", model: "other", totalTokens: 76, hasUnresolvedRequestedModel: true }); + expect(summary.days.flatMap(day => day.models).at(-1)).toMatchObject({ model: "other", hasUnresolvedRequestedModel: true }); + expect(summary.summary.totalTokens).toBe(rows.length * 38); + }); +}); + describe("parseRange", () => { test("accepts 7d / 30d / all", () => { expect(parseRange("7d")).toBe("7d"); From f0cad26a10663c25ea0f8748943e5f87c2c1e572 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 12:59:50 +0900 Subject: [PATCH 03/10] fix(usage): retain rejected selectors and handle legacy provider IDs --- .../002_audit_synthesis.md | 7 ++ .../012_premerge_review.md | 18 +++++ .../020_account_quota_api.md | 74 +++++++------------ .../040_stack_landing.md | 9 +++ .../ProviderWorkspaceShell.tsx | 5 +- gui/src/provider-workspace/usage.ts | 11 ++- gui/tests/provider-usage-attribution.test.tsx | 21 +++++- src/server/chat-completions.ts | 1 + src/server/claude-messages.ts | 1 + .../routing-policy-surface-parity.test.ts | 34 ++++++++- 10 files changed, 129 insertions(+), 52 deletions(-) create mode 100644 devlog/_plan/260905_provider_usage_quota_parity/012_premerge_review.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/002_audit_synthesis.md b/devlog/_plan/260905_provider_usage_quota_parity/002_audit_synthesis.md index bc5f0f98be..33c717f716 100644 --- a/devlog/_plan/260905_provider_usage_quota_parity/002_audit_synthesis.md +++ b/devlog/_plan/260905_provider_usage_quota_parity/002_audit_synthesis.md @@ -16,3 +16,10 @@ signature now uses the same boolean refresh argument as 030 (force and await tog Corrected the Logs projection owner name to `requestLogDto`. Scope-lock also removes the unnecessary global scheduler/forced-successor design; bounded per-roster workers retain the required capability without changing global report scheduling. Re-audit only these deltas. + +Final delta re-audit by Kant completed before roadmap B: both remaining blockers closed, +private identity guard and boolean refresh arguments confirmed, `requestLogDto` anchor corrected. +Recorded verdict: "Blocking issues: none. Design-only approval; no tests or mutations performed. +VERDICT: PASS". The session ledger's roadmap A→B attestation records that verdict; roadmap +commit00b244e7a closes the docs-only delivery. Repository integration and runtime deployment +remain separate, as040 requires; no service restart is implied by any roadmap or merge result. diff --git a/devlog/_plan/260905_provider_usage_quota_parity/012_premerge_review.md b/devlog/_plan/260905_provider_usage_quota_parity/012_premerge_review.md new file mode 100644 index 0000000000..a15b2fdba7 --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/012_premerge_review.md @@ -0,0 +1,18 @@ +# Pre-merge review closure + +The final live review refresh found additional CodeRabbit items after the initial independent +review. No merge was attempted while those findings remained open. + +- Provider IDs matching Object properties are valid historical ledger data even when current + configuration rejects them. Both provider-total and model-group projections now use + null-prototype records, with shared production helpers and `__proto__`/`constructor` cases. +- New missing-policy early returns retain requestedModel in Chat and Messages final logs. + A regression supplies logIds and checks persisted404 rows inside its own temporary home. +- 002 now records the actual final roadmap delta re-audit PASS rather than ending at the + preceding request to re-audit. 020's rejected scheduler/successor directives and test + references have been replaced with the final per-roster/single-flight contract. +- Credential-reader redirect rejection is implemented and tested in the dependent quota API + layer (#3584). This attribution layer does not enable or modify those readers. + +The bottom-branch repair is cascaded to both upper branches before publication. No local tests, +typecheck, build, lint or scan are run; each changed stack head requires fresh remote CI. diff --git a/devlog/_plan/260905_provider_usage_quota_parity/020_account_quota_api.md b/devlog/_plan/260905_provider_usage_quota_parity/020_account_quota_api.md index bf103a4355..d104d4544c 100644 --- a/devlog/_plan/260905_provider_usage_quota_parity/020_account_quota_api.md +++ b/devlog/_plan/260905_provider_usage_quota_parity/020_account_quota_api.md @@ -53,7 +53,7 @@ Anchors refer to source inspected on 2026-09-05; refresh line numbers before imp No-code options: doing nothing leaves missing required readers; deletion hides useful readings; configuration cannot change account binding; reuse is selected for readers, token resolution, normalizers, key resolution, report display and existing routes. A parallel HTTP client is not -justified. A small quota-owned scheduler and isolated key-cache module are justified by scope: +justified. A per-roster worker mapper and isolated key-cache module are justified by scope: `src/oauth/token-guardian.ts:115` has a private per-call worker loop, not a reusable global quota limiter. Do not import token-guardian lifecycle into the quota path. @@ -421,7 +421,7 @@ validated configured provider name -> read-only pool/legacy snapshot existing `LAST_GOOD_MAX_AGE_MS` (30 minutes). Failure advances attempt TTL, not measurement `quota.updatedAt`. Null/terminal/authoritative-empty conversions follow section 4. - Cap key cache at 256 entries, evict expired then least-recently-used settled entries on access - or write. No new timer. Bound in-flight/queued entries separately. Do not persist key rows. + or write. No new timer or scheduler queue. Bound key in-flight entries separately. Do not persist key rows. - `clearProviderQuotaCache` invalidates key-flight commit authority as well as key settled rows. Use a module epoch; post-await commits check epoch and current entry ownership. A changed key, provider destination, removed row or replaced provider cannot publish stale enrichment even to @@ -435,48 +435,32 @@ validated configured provider name -> read-only pool/legacy snapshot report with an inactive key, and never changes `activeId` or `provider.apiKey`. Current reports continue using the actual active credential; no key aggregation or account weighting is added. -### 8.2 Bounded scheduling - -Add `src/providers/quota-probe-scheduler.ts`, lazy process-local state with no startup timer: -four admitted quota transactions at once, at most 64 queued transactions; excess returns a -typed unavailable outcome. Each account/key request uses at most four worker promises pulling -the roster in input order, not one immediately executing promise per credential. All roster -rows are represented; overflow is unavailable, never silently truncated or zero. - -Acquire admission **after cache/single-flight checks and once per reader transaction**, before -token resolution/wire calls. Never acquire again inside a nested helper/dispatch: no deadlock. -Provider-level reads of the same readers use the same admission boundary; cached and passive -reads consume no permit. Existing A6API transaction performs two parallel requests, so four -transactions bound quota HTTP fanout to at most eight for the current reader set; OAuth refresh -requests remain governed by their existing refresh-flight owner. Do not claim four total network -requests or introduce a second lock around the same token renewal. - -Queue entries time out after 30 seconds and cancel without starting; active transactions keep -their permit until their bounded reader work actually settles. Preserve `REQUEST_TIMEOUT_MS=8000` -and `QUOTA_RESPONSE_MAX_BYTES=512 KiB`, including response-body bounds and finite Cursor/Command -Code fallback sequences. No retries beyond existing reader fallbacks. A UI timeout must not -release a permit while its upstream work is still running. The endpoint's total latency can -span multiple waves; do not claim it completes within one upstream timeout. +### 8.2 Bounded roster reads — final scope + +Use `mapQuotaRoster` in the key-account quota owner for at most four workers per roster, +preserving input order. This is not a process-wide HTTP concurrency guarantee. No global +scheduler module, queue, admission timer, or changes to provider-report scheduling are required. +Keep identity-keyed single-flight, bounded key-cache/in-flight maps, existing OAuth renewal +locks, 8-second wire deadlines and bounded response bodies. A reader may issue sequential or +parallel protocol calls; total roster latency can span multiple waves. These final requirements +replace the rejected global-scheduler proposal, rather than coexisting with it. ### 8.3 Force versus in-flight requests -Existing `fetchAccountQuota` unconditionally joins an in-flight read (`quota.ts:1710`). Replace -the flight value with `{ promise, forced, operationEpoch, identity }` and enforce: +Preserve the existing join semantics, with identity/clear guards for the new readers: | Request | Cache / flight behavior | | --- | --- | -| Ordinary | Reuse matching fresh settled entry, else join matching current flight, else schedule | -| Forced, no flight | Bypass positive and negative TTL and schedule one new probe | -| Forced, forced flight exists | Join the same identity's forced flight; no duplicate spending | -| Forced, ordinary flight exists | Elect one shared forced successor, wait for old flight settlement, then probe; do not report old cached/ordinary result as forced | +| Ordinary | Reuse matching fresh settled entry, else join matching current flight, else read | +| Forced, no flight | Bypass positive and negative TTL and start one new read | +| Forced, any matching flight exists | Join that in-flight read and await settlement; no successor probe | | Clear/remove/identity change | Invalidate old operation authority; no late cache or response publication under the replacement | -| Passive force | Cache read only, unchanged observation time, no token renewal, no admission slot | -| Unsupported force | Return mode only, no token resolution, no admission slot | +| Passive force | Cache read only, unchanged observation time, no token renewal | +| Unsupported force | Return mode only, no token resolution | -The elected forced successor supersedes older write authority, but does not race a second token -renewal against the ordinary flight. `finally` removes only its own flight entry. Apply the same -semantics to the new key cache. A current report cache hit is never evidence that all account -rows refreshed. Forced quota refresh does not mean force-refreshing an otherwise valid token. +`finally` removes only its own flight entry. Apply the same semantics to the new key cache. +A current report cache hit is not evidence that all account rows refreshed. Forced quota +refresh does not mean renewing an otherwise valid token or requiring an extra successor read. ## 9. 030 handoff: precise load and refresh settlement @@ -522,13 +506,13 @@ negative tests land. Production edits listed below are planned, not made by this | Layer | Files and changes | Dependency / acceptance | | --- | --- | --- | | A — row contract + dispatch foundation | `src/providers/quota-types.ts`: mode/fields; `src/providers/quota.ts`: mode predicates, explicit unsupported branch, pure key reader selector; `src/server/management/oauth-account-routes.ts`: cheap row mode; `src/providers/api-keys.ts`: pure legacy projection and type-only quota fields | Existing supported modes only until B; cheap GET does no upstream/secret/config writes | -| B — four OAuth readers | `src/providers/quota.ts`: exact signatures in section 6, paired context, active-report call sites, sentinel conversion, four allowlist additions, flight fences; `src/providers/quota-probe-scheduler.ts` (new): shared bounded admission | A; every dedicated reader is tested with at least two distinct accounts, no active switch | -| C — all key rows | `src/providers/quota-key-accounts.ts` (new): isolated config/cache/fanout; `src/providers/quota.ts`: facade and uncached callback, cache invalidation integration; `src/server/management/oauth-account-routes.ts`: key opt-in enrichment | A+B scheduler; every existing supported key dispatch admitted; no provider cache contamination | +| B — four OAuth readers | `src/providers/quota.ts`: exact signatures in section 6, paired context, active-report call sites, sentinel conversion, four allowlist additions and flight fences; reuse the per-roster mapper in the key-account owner | A; every dedicated reader is tested with at least two distinct accounts, no active switch | +| C — all key rows | `src/providers/quota-key-accounts.ts` (new): isolated config/cache/per-roster mapper; `src/providers/quota.ts`: facade and uncached callback, cache invalidation integration; `src/server/management/oauth-account-routes.ts`: key opt-in enrichment | A+B reader contracts; all supported key dispatch retained; no provider cache contamination | | D — backend contract audit | Existing backend regression files below; `structure/05_gui-and-management-api.md`: row modes/query semantics/refresh outcome (parent scope); `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json` if new backend regression files are added | A-C; DTO/privacy/race checks and parent exact-head CI before 030 consumption | | 030 — UI consumer, separately owned | `gui/src/hooks/useProviderAccountPools.ts`, `gui/src/components/provider-workspace/types.ts`, `gui/src/pages/Providers.tsx`, `ProviderDetails.tsx`, `ProviderAuthPanel.tsx`, `ProviderUsage.tsx`, `ProviderCapacityQuota.tsx`, `gui/src/provider-workspace/report.ts`, all affected i18n locales | Backend A-D; loading/refresh and current-vs-all rendering follows section 9 | Keep new modules focused and under the dev modularity limits. `quota.ts` is already large; -do not append the independent key-cache/scheduler implementations to it or opportunistically +do not append the independent key-cache implementation to it or opportunistically move every existing provider parser. No changes to `src/oauth/index.ts`/store persistence are required by this design. If paired context cannot be obtained with the existing account resolver, parent must explicitly amend scope before changing auth internals. @@ -549,10 +533,9 @@ Use synthetic tokens and local mocked transports only; assertions must not print | `tests/providers/muse-passive-quota-cache.test.ts` | Mode passive while supportsPerAccountQuota remains false; hydration before persistence; account revision fence; observed roster only; no-observation omitted, not error; restart retains observation | | `tests/providers/muse-passive-quota-observation.test.ts` | Cheap list mode only; enriched passive row mode + original quota timestamp; forced read makes zero network/renewal calls; unobserved passive row has no unavailable flag; active/current selection remains distinct from stored-account observations | | `tests/providers/provider-quota-observed-marker.test.ts` | Preserve provider-report observed marker and freshness exemptions | -| `tests/providers/kiro/kiro-account-quota.test.ts` | Existing Kiro context/CLI refresh, regional metadata and exhaustion-state commit remain intact after scheduler introduction | +| `tests/providers/kiro/kiro-account-quota.test.ts` | Existing Kiro context/CLI refresh, regional metadata and exhaustion-state commit remain intact after bounded roster mapping | | `tests/providers/provider-account-quota-persistence.test.ts` | No API-key cache entries/digests in OAuth disk snapshot; OAuth/passive hydration unchanged; old persisted new-reader row cannot masquerade as a freshly verified identity | -| `tests/providers/provider-key-account-quota.test.ts` (new) | Full supported key-reader matrix; cross-provider same id; key replacement same id; env/keychain reference resolves to new key; base/auth/adapter change; deletion/readdition; config freeze; no cache bleed to current report/OAuth; transient vs terminal vs authoritative-empty; force and negative TTL; cache cap/eviction | -| `tests/providers/quota-probe-scheduler.test.ts` (new) | Four transaction ceiling across overlapping batches; A6API two-request accounting; queue cap/timeout; passive/unsupported/cache hit takes no permit; FIFO/no starvation within admitted queue; rejection/finally releases exactly once; no nested-lock deadlock; overflow rows unavailable; no abandoned task releases early | +| Existing `tests/providers/provider-api-keys.test.ts` | Extend for supported key-reader matrix, replacement/clear, frozen config, no provider/OAuth cache bleed, failure/empty semantics, cache caps and four workers per roster; no separate scheduler test file | Additional reader fixtures within those files must force each existing protocol branch: @@ -562,9 +545,8 @@ Additional reader fixtures within those files must force each existing protocol each with distinct two-account tokens, date parsing, and redirect rejection. 3. Kimi custom/standard window parsing, canonical configured base, explicitly invalid base, OAuth token versus isolated coding-plan key, missing/invalid payload. -4. Force during an ordinary flight starts exactly one successor; concurrent forced requests join; - late ordinary result cannot overwrite forced result; remove/clear during either flight cannot - revive a row. Successful force bypasses both positive and negative ten-minute TTL. +4. Force bypasses positive and negative ten-minute TTL but joins a matching in-flight read; + concurrent forced requests share it. Remove/clear during a flight cannot revive a row. 5. One failed row does not drop healthy siblings; one malformed upstream body cannot serialize raw fields. Real 0%, empty/no windows, unsupported and unavailable remain four distinct cases. @@ -584,7 +566,7 @@ exact-head CI and the audit cycle. Candidate focused CI invocations (not run her ```sh bun test tests/providers/provider-account-quota.test.ts tests/providers/provider-quota.test.ts tests/providers/command-code-quota.test.ts -bun test tests/providers/provider-api-keys.test.ts tests/providers/provider-key-account-quota.test.ts tests/providers/quota-probe-scheduler.test.ts +bun test tests/providers/provider-api-keys.test.ts bun test tests/oauth/oauth-accounts-api.test.ts tests/providers/muse-passive-quota-cache.test.ts tests/providers/muse-passive-quota-observation.test.ts bun test tests/providers/kiro/kiro-account-quota.test.ts tests/providers/provider-account-quota-persistence.test.ts tests/providers/provider-quota-observed-marker.test.ts tests/providers/opencode-go-quota.test.ts ``` diff --git a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md index b5da6f82bb..833401fac4 100644 --- a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md +++ b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md @@ -19,4 +19,13 @@ All exact-head CI jobs passed, original symptom and quota state matrix observed, ## Verifier and terminal conditions +Pre-merge review remediation: preserve the rejected selector in Chat/Messages early 404 logs +(`src/server/{chat-completions,claude-messages}.ts` and the existing policy surface regression), +and use null-prototype provider-keyed accumulators in the provider workspace with `__proto__` +and `constructor` regression rows. Record final roadmap audit closure and clearly mark the +superseded global-scheduler design in020. These remain the attribution layer's thesis; amend +the bottom branch, cascade every upper branch before pushing, then require renewed exact-head +CI. Credential-reader redirect controls belong to the already implemented API layer, not to +the attribution layer's executable scope. + CLI GitHub reads are bounded, at most one fresh rollup per meaningful head/state change. Capture C receipt using the exact-head CI verification command. DONE only with all ancestry proofs; wait for pending CI using bounded polling, never call pending CI a blocker. diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index c303f7ad43..fd30f873da 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -23,7 +23,7 @@ import { import { providerKind } from "../../provider-workspace/kind"; import { readJsonIfOk, readJsonOrThrow } from "../../fetch-json"; import { readSessionListCache, writeSessionListCache } from "../../session-list-cache"; -import { buildProviderModelUsage, countAvailableModels, parseAvailableModels, parseLiveModelCounts, parseSelectedModels, type ProviderAvailableModels, type ProviderLiveModelCounts, type ProviderModelCounts, type ProviderSelectedModels } from "../../provider-workspace/usage"; +import { buildProviderModelUsage, buildProviderUsageTotals, countAvailableModels, parseAvailableModels, parseLiveModelCounts, parseSelectedModels, type ProviderAvailableModels, type ProviderLiveModelCounts, type ProviderModelCounts, type ProviderSelectedModels } from "../../provider-workspace/usage"; import { freshQuotaReportRecord, freshQuotaReportsFromResponse, @@ -208,8 +208,7 @@ export default function ProviderWorkspaceShell({ if (usageResource.loading) setUsageLoading(!readSessionListCache(usageCacheKey)); return; } - const byProvider: Record = {}; - for (const row of data.providers ?? []) byProvider[row.provider] = { requests: row.requests, totalTokens: row.totalTokens }; + const byProvider = buildProviderUsageTotals(data.providers ?? []); setUsageTotals(byProvider); const byProviderModels = buildProviderModelUsage(data.models ?? [], byProvider); setUsageModels(byProviderModels); diff --git a/gui/src/provider-workspace/usage.ts b/gui/src/provider-workspace/usage.ts index 5f7b0cee7e..033bdbcee2 100644 --- a/gui/src/provider-workspace/usage.ts +++ b/gui/src/provider-workspace/usage.ts @@ -81,12 +81,21 @@ export interface ProviderUsageTotals { totalTokens?: number; } +/** Ledger provider IDs are data, including legacy names that match Object properties. */ +export function buildProviderUsageTotals( + providers: readonly (ProviderUsageTotals & { provider: string })[], +): Record { + const totals: Record = Object.create(null); + for (const row of providers) totals[row.provider] = { requests: row.requests, totalTokens: row.totalTokens }; + return totals; +} + /** Keep serving-provider attribution while computing shares within each provider. */ export function buildProviderModelUsage( models: readonly (ProviderModelUsageRow & { provider: string })[], totals: Record, ): Record { - const result: Record = {}; + const result: Record = Object.create(null); for (const row of models) { const providerTokens = totals[row.provider]?.totalTokens ?? 0; const { provider, ...model } = row; diff --git a/gui/tests/provider-usage-attribution.test.tsx b/gui/tests/provider-usage-attribution.test.tsx index 9b456fd328..1820942572 100644 --- a/gui/tests/provider-usage-attribution.test.tsx +++ b/gui/tests/provider-usage-attribution.test.tsx @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import ProviderUsage from "../src/components/provider-workspace/ProviderUsage"; import { LanguageProvider } from "../src/i18n/provider"; -import { buildProviderModelUsage } from "../src/provider-workspace/usage"; +import { buildProviderModelUsage, buildProviderUsageTotals } from "../src/provider-workspace/usage"; import type { WorkspaceItem } from "../src/provider-workspace/catalog"; const item: WorkspaceItem = { @@ -11,6 +11,25 @@ const item: WorkspaceItem = { }; const base = { requests: 1, inputTokens: 70, outputTokens: 10, totalTokens: 80, shareRatio: 0.008 }; +test("prototype-shaped provider IDs remain ordinary data in totals and model groups", () => { + const totals = buildProviderUsageTotals([ + { provider: "__proto__", requests: 2, totalTokens: 100 }, + { provider: "constructor", requests: 3, totalTokens: 200 }, + ]); + const models = buildProviderModelUsage([ + { ...base, provider: "__proto__", model: "legacy-a" }, + { ...base, provider: "constructor", model: "legacy-b" }, + ], totals); + expect(Object.getPrototypeOf(totals)).toBeNull(); + expect(Object.getPrototypeOf(models)).toBeNull(); + expect(Object.keys(totals).sort()).toEqual(["__proto__", "constructor"]); + const expected: Array<[string, number, number]> = [["__proto__", 2, 0.8], ["constructor", 3, 0.4]]; + for (const [provider, requests, share] of expected) { + expect(totals[provider]?.requests).toBe(requests); + expect(models[provider]?.[0]?.shareRatio).toBe(share); + } +}); + test("model grouping preserves serving provider and uses provider-local shares", () => { const rows = buildProviderModelUsage([ { ...base, provider: "kimi", model: "anthropic/claude-opus-5", hasUnresolvedRequestedModel: true }, diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 6df2b70cef..afafe4f56e 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -147,6 +147,7 @@ async function handleChatCompletionsWithBudget( if (!effortRow && isNativeChatRouteEligible(route, chatBody)) chatNativeRoute = route; } catch (err) { if (err instanceof UnknownRoutingPolicyError) { + logCtx.requestedModel = requestedModel; if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" }); return chatCompletionsErrorResponse(404, err.message, "invalid_request_error"); } diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 4be12d932d..595928c0c3 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -764,6 +764,7 @@ async function handleClaudeMessagesWithBudget( } } catch (err) { if (err instanceof UnknownRoutingPolicyError) { + logCtx.requestedModel = requestedModel; if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" }); return anthropicErrorResponse(404, err.message, "invalid_request_error"); } diff --git a/tests/routing/routing-policy-surface-parity.test.ts b/tests/routing/routing-policy-surface-parity.test.ts index 810c326a66..d73c5ff483 100644 --- a/tests/routing/routing-policy-surface-parity.test.ts +++ b/tests/routing/routing-policy-surface-parity.test.ts @@ -1,11 +1,16 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { chatCompletionsToResponsesBody } from "../../src/chat/inbound"; import { anthropicToResponsesTranslation } from "../../src/claude/inbound"; import { evidenceFromBody } from "../../src/routing/request-evidence"; import type { ProviderAdapter } from "../../src/adapters/base"; import type { AdapterEvent, OcxConfig, OcxProviderConfig } from "../../src/types"; -import type { RequestLogContext } from "../../src/server/request-log"; +import { clearRequestLogsForTests, type RequestLogContext } from "../../src/server/request-log"; +import { readUsageEntries } from "../../src/usage/log"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; const MODEL = "policy/daily"; const EXPECTED_RICH_EVIDENCE = { @@ -162,6 +167,33 @@ function minimalSuccessAdapter(provider: OcxProviderConfig): ProviderAdapter { } describe("routing policy request evidence parity (via dev handlers)", () => { + test("finalized Chat and Messages policy errors retain the rejected selector", async () => { + const previousHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-policy-log-")); + process.env.OPENCODEX_HOME = home; + clearRequestLogsForTests(); + try { + for (const [wire, handler, body] of [ + ["chat", handleChatCompletions, { model: "policy/missing", messages: [{ role: "user", content: "hello" }] }], + ["messages", handleClaudeMessages, { model: "policy/missing", max_tokens: 64, messages: [{ role: "user", content: "hello" }] }], + ] as const) { + const requestId = `policy-log-${wire}`; + const path = wire === "chat" ? "/v1/chat/completions" : "/v1/messages"; + const response = await handler(new Request(`http://localhost${path}`, { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }), testConfig(), { model: "", provider: "" }, { requestId, start: Date.now() }); + expect(response.status).toBe(404); + const entry = readUsageEntries().find(row => row.requestId === requestId); + expect(entry?.requestedModel).toBe("policy/missing"); + expect(entry?.status).toBe(404); + } + } finally { + clearRequestLogsForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + } + }); test("missing and empty policies return compatible 404s on every wire before adapter resolution", async () => { let adapterCalls = 0; adapterFactory = provider => { From 63510ffed34fa6ef191d6c1b9464f2f6f06edf54 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 13:37:19 +0900 Subject: [PATCH 04/10] fix(quota): reconcile reset integration contracts --- .../012_premerge_review.md | 2 +- .../040_stack_landing.md | 19 ++++++++++ .../ocx/references/01_management_surface.md | 17 ++++++++- src/cli/capabilities.ts | 11 ++++++ src/server/management-api.ts | 2 +- src/server/management/route-registry.ts | 2 ++ tests/gui/rate-limit-reset-credits.test.ts | 8 ++++- tests/usage/quota-reset-notify.test.ts | 35 ++++++++++++++++++- 8 files changed, 91 insertions(+), 5 deletions(-) diff --git a/devlog/_plan/260905_provider_usage_quota_parity/012_premerge_review.md b/devlog/_plan/260905_provider_usage_quota_parity/012_premerge_review.md index a15b2fdba7..6330d6695d 100644 --- a/devlog/_plan/260905_provider_usage_quota_parity/012_premerge_review.md +++ b/devlog/_plan/260905_provider_usage_quota_parity/012_premerge_review.md @@ -7,7 +7,7 @@ review. No merge was attempted while those findings remained open. configuration rejects them. Both provider-total and model-group projections now use null-prototype records, with shared production helpers and `__proto__`/`constructor` cases. - New missing-policy early returns retain requestedModel in Chat and Messages final logs. - A regression supplies logIds and checks persisted404 rows inside its own temporary home. + A regression supplies logIds and checks persisted 404 rows inside its own temporary home. - 002 now records the actual final roadmap delta re-audit PASS rather than ending at the preceding request to re-audit. 020's rejected scheduler/successor directives and test references have been replaced with the final per-roster/single-flight contract. diff --git a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md index 833401fac4..08edc16847 100644 --- a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md +++ b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md @@ -28,4 +28,23 @@ the bottom branch, cascade every upper branch before pushing, then require renew CI. Credential-reader redirect controls belong to the already implemented API layer, not to the attribution layer's executable scope. +Freeze the integration baseline at fetched `dev`55395a9dc. It adds Antigravity weekly and +Ollama Cloud quota support during this task. Preserve both implementations and the optional +reset observer while merging the baseline into the stack. Resolve the quota dispatch conflict +by retaining the shared key-reader selector and registering the incoming canonical Ollama +reader there; add a per-key Ollama regression. All layers must receive the integrated baseline +before publication and new exact-head CI. Do not chase unrelated later changes without a +concrete integration conflict or verifier requirement. + +The integrated baseline's remote CI exposed three concrete quota-reset contract gaps: +an undeclared management route/lazy dispatch guard, a strict expected quota shape missing +`shortObservedAt`, and an HTTP webhook fixture rejected by the existing HTTPS schema. +Repair these integration gates in the bottom layer and cascade both children. Register the +existing `provider resets` command and route without an exemption, retain exact quota +assertions, and bridge only the test's HTTPS transport to its local receiver. Do not relax +HTTPS/SSRF protections or run local validation. Kant independently reviewed both the two-test +delta and the four-file route/capability delta: PASS, including explicit security review +of unchanged authentication, exact inner method/path guards and lazy imports. All three +new heads still require remote CI. + CLI GitHub reads are bounded, at most one fresh rollup per meaningful head/state change. Capture C receipt using the exact-head CI verification command. DONE only with all ancestry proofs; wait for pending CI using bounded polling, never call pending CI a blocker. diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 36438d2f68..fb82c23360 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -72,6 +72,21 @@ JSON mode: `envelope`. - Reads local config; drives no management API route. +### `ocx provider resets` + +List recently detected quota-window resets and whether detection is enabled. + +| Method | Route | +|---|---| +| GET | `/api/quota-resets` | + +| Flag | Value | Meaning | +|---|---|---| +| `--limit` | number | Maximum events to return (default 20, capped at 100; non-negative integer). | +| `--json` | boolean | Emit the quota-reset payload as JSON. | + +JSON mode: `payload`. + ### `ocx account list` Codex OAuth accounts with pool priority and pause state. @@ -587,6 +602,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 32 +- declared capabilities: 33 - of those, state-changing: 13 - head-resolved invocations: 2 diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 6fcdd7cb16..2f86050a5d 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -153,6 +153,17 @@ export const CAPABILITIES: readonly Capability[] = [ json: "envelope", details: ["Reads local config; drives no management API route."], }, + { + command: ["provider", "resets"], + summary: "List recently detected quota-window resets and whether detection is enabled.", + routes: [{ method: "GET", path: "/api/quota-resets" }], + flags: [ + { name: "--limit", value: "number", summary: "Maximum events to return (default 20, capped at 100; non-negative integer)." }, + { name: "--json", value: "boolean", summary: "Emit the quota-reset payload as JSON." }, + ], + mutates: false, + json: "payload", + }, { command: ["provider", "keychain"], summary: "Move a provider's API key into the OS keychain, restore it, or report where it lives.", diff --git a/src/server/management-api.ts b/src/server/management-api.ts index f5478a8077..f1749bc78e 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -138,7 +138,7 @@ async function handleLabRoutesOnDemand(ctx: ManagementContext): Promise { - if (ctx.url.pathname !== "/api/quota-resets") return null; + if (!pathInManagementNamespace(ctx.url.pathname, "/api/quota-resets")) return null; const { handleQuotaResetRoutes } = await import("./management/quota-reset-routes"); return handleQuotaResetRoutes(ctx); } diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 8add87803d..3b12949334 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -276,6 +276,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "POST", path: "/api/providers/test", module: "server/management/provider-routes", mutates: true }, { method: "PUT", path: "/api/providers", module: "server/management/provider-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Issue #3280 scopes this atomic batch endpoint to the GUI JSON editor; a matching CLI verb is outside wp5 and remains owed.", owner: "wp5-followup", ownerDoc: "devlog/_plan/260903_bug_drawdown_bcda/050_phase5.md" } }, { method: "PUT", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: true }, + // server/management/quota-reset-routes + { method: "GET", path: "/api/quota-resets", module: "server/management/quota-reset-routes", mutates: false }, // server/management/request-history-routes { method: "GET", path: "/api/request-history", module: "server/management/request-history-routes", mutates: false }, // server/management/routing-analytics-routes diff --git a/tests/gui/rate-limit-reset-credits.test.ts b/tests/gui/rate-limit-reset-credits.test.ts index 4300136701..9db59f1d3f 100644 --- a/tests/gui/rate-limit-reset-credits.test.ts +++ b/tests/gui/rate-limit-reset-credits.test.ts @@ -499,15 +499,21 @@ describe("rate-limit reset credits", () => { "x-codex-secondary-window-minutes": "10080", "x-codex-secondary-reset-at": "1788000000", }); + const observedAfter = Date.now(); applyAccountQuotaFromUpstreamHeaders("burst-A", headers); - expect(getAccountQuota("burst-A")).toEqual({ + const quota = getAccountQuota("burst-A"); + expect(quota).toEqual({ shortPercent: 97, shortResetAt: 1787401330, shortWindowSeconds: 18000, + shortObservedAt: expect.any(Number), weeklyPercent: 12, weeklyResetAt: 1788000000, updatedAt: expect.any(Number), }); + expect(quota!.shortObservedAt).toBe(quota!.updatedAt); + expect(quota!.shortObservedAt).toBeGreaterThanOrEqual(observedAfter); + expect(quota!.shortObservedAt).toBeLessThanOrEqual(Date.now()); }); it("an exhausted burst window does not poison the weekly reading", () => { diff --git a/tests/usage/quota-reset-notify.test.ts b/tests/usage/quota-reset-notify.test.ts index fb053e10d8..b17cd0ecc6 100644 --- a/tests/usage/quota-reset-notify.test.ts +++ b/tests/usage/quota-reset-notify.test.ts @@ -306,6 +306,21 @@ describe("enablement", () => { }); describe("config integration", () => { + test("private-network opt-in does not permit a cleartext webhook URL", () => { + const result = validateConfigCandidate({ + port: 10100, + defaultProvider: "openai", + providers: { openai: { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1" } }, + quotaResetNotify: { + enabled: true, + webhookUrl: "http://127.0.0.1:9999/hook", + allowPrivateNetwork: true, + }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("webhookUrl"); + }); + test("an invalid notify section is rejected by the write path", () => { // Live writes stay strict, so an operator is told rather than silently ignored. const result = validateConfigCandidate({ @@ -490,6 +505,9 @@ describe("activation is the single switch", () => { }, }); + const webhookUrl = "https://quota-reset-fixture.example.test/hook"; + const realFetch = globalThis.fetch; + const deliveredRequests: Array<{ url: string; method?: string; redirect?: RequestInit["redirect"] }> = []; const home = mkdtempSync(join(tmpdir(), "ocx-live-")); writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10100, @@ -499,7 +517,7 @@ describe("activation is the single switch", () => { }, quotaResetNotify: { enabled: true, - webhookUrl: `http://127.0.0.1:${server.port}/hook`, + webhookUrl, allowPrivateNetwork: true, // Passive-only: this asserts the live request path fires without any timer involved. pollSeconds: 0, @@ -509,6 +527,15 @@ describe("activation is the single switch", () => { const previousHome = process.env["OPENCODEX_HOME"]; process.env["OPENCODEX_HOME"] = home; try { + // Config validation still sees HTTPS. Bridge only the transport to the local + // receiver; activation, observation, reset detection and payload encoding stay real. + // Never fall through to the network for an unexpected destination. + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + deliveredRequests.push({ url, method: init?.method, redirect: init?.redirect }); + if (url !== webhookUrl) throw new Error("Unexpected webhook fixture destination"); + return realFetch(`http://127.0.0.1:${server.port}/hook`, init); + }) as typeof globalThis.fetch; resetQuotaResetNotifyCacheForTests(); resetQuotaResetStoreForTests(); resetQuotaResetActivationForTests(); @@ -531,6 +558,7 @@ describe("activation is the single switch", () => { } expect(bodies).toHaveLength(1); + expect(deliveredRequests).toEqual([{ url: webhookUrl, method: "POST", redirect: "manual" }]); const payload = JSON.parse(bodies[0] ?? "{}") as Record; expect(payload["type"]).toBe("quota_reset"); expect(payload["kind"]).toBe("scheduled"); @@ -538,11 +566,16 @@ describe("activation is the single switch", () => { expect(payload["percentBefore"]).toBe(96); expect(payload["percentAfter"]).toBe(2); expect(bodies[0]).not.toContain("operator@example.com"); + expect(bodies[0]).not.toContain("@"); + expect(bodies[0]).not.toContain("/Users/"); + expect(payload).not.toHaveProperty("accountId"); + expect(payload).not.toHaveProperty("key"); } finally { setQuotaResetSink(null); resetQuotaResetActivationForTests(); resetQuotaResetNotifyCacheForTests(); clearAccountQuota(); + globalThis.fetch = realFetch; server.stop(true); if (previousHome === undefined) delete process.env["OPENCODEX_HOME"]; else process.env["OPENCODEX_HOME"] = previousHome; From 4a9702726ce43747c66a9808192e7a6e7dc8b60a Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 14:18:48 +0900 Subject: [PATCH 05/10] docs(quota): clarify webhook fixture evidence --- .../260905_provider_usage_quota_parity/040_stack_landing.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md index 1c82459315..b0e9c674c5 100644 --- a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md +++ b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md @@ -50,10 +50,14 @@ new heads still require remote CI. Concrete follow-on conflict: upstream PR #3622 landed the same quota-reset integration repairs, followed by #3623's update-test diagnostic change, at `dev`1c1ca060a. Preserve both commits. Use upstream's route/capability declarations and generated reference verbatim; retain this -unit's stricter observation-time and HTTPS-transport/privacy assertions without duplicate +unit's stricter observation-time, HTTPS-schema and payload-privacy assertions without duplicate properties or fixtures. This conflict resolution, not unrelated base chasing, advances the frozen baseline. Cascade every child and require fresh exact-head CI. +The webhook fixture bridges an HTTPS-shaped test URL to an HTTP loopback receiver. It +verifies configuration acceptance and activation/delivery, not TLS negotiation or certificate +validation. These remain outside this fixture's evidence claim. + The Windows stabilization stack then landed #3610/#3613 at `dev`be81013fa, creating another concrete conflict in the same webhook fixture. Adopt its portable receiver-promise wait and fetch shim intact; keep only this unit's additional HTTPS-schema rejection and payload From 30faf65629c592bd1d44a4c59a276839347de917 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 14:34:03 +0900 Subject: [PATCH 06/10] test(lab): isolate output byte limit from timeout fixtures --- .../013_ci_test_boundaries.md | 36 +++++++++++++++++++ .../040_stack_landing.md | 10 ++++++ tests/lab/lab-live-pinned-timeouts.test.ts | 31 ++++++++++++++-- 3 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 devlog/_plan/260905_provider_usage_quota_parity/013_ci_test_boundaries.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/013_ci_test_boundaries.md b/devlog/_plan/260905_provider_usage_quota_parity/013_ci_test_boundaries.md new file mode 100644 index 0000000000..b1a18d24e7 --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/013_ci_test_boundaries.md @@ -0,0 +1,36 @@ +# CI test-boundary evidence + +## Launcher observation: not closed + +API run 33945229815 attempt 2, job 101253134093, failed the SIGINT-labelled launcher +case before any signal was sent: readiness exceeded 60000ms, launcher alive, no output. +The next SIGTERM/SIGHUP cases passed in 770.07ms/758.94ms. The launcher/startup files +match frozen dev. Two baseline jobs passed, so an identical baseline failure is not proven. +The doc-only successor, API job 101254594969 in run 33946878385, passed all three cases +in 1016.49ms/1017.85ms/1017.39ms. This measures variability, not a causal repair. +No launcher code, startup budget, retry or skip was changed; cause remains unresolved. + +## Byte-limit fixture: competing guard + +UI run 33946877992, macOS job 101254603450: 8867 passed, 1 skipped, 1 failed. +The output-byte test expected `output_byte_limit` but received `first_byte_timeout` +after 48.74ms. Both dedicated timeout cases passed. + +| Hypothesis | Falsifier and observed evidence | Disposition | +|---|---|---| +| Quota changes broke transport error mapping | A changed pinned transport/sender or wrong mapping would support this; both match frozen dev and preserve distinct typed errors | Unsupported by source/diff | +| Byte accounting rejected the wrong size | Reaching the data handler with the wrong count would support this; the observed failure occurred before response headers | Not the observed failing branch | +| Unrelated short fixture deadlines preempted byte enforcement | A byte error with no first-byte timeout would refute this instance; the case inherited 30ms and the log names that timer's error | Confirmed immediate mechanism | + +Why response headers took over 30ms is not established; runner contention is not claimed. +The correction isolates the property under test, not a production timeout: the byte-case +budgets alone become 1000ms, while a deliberately delayed 150ms response makes the old +30ms preemption observable on fast machines too. The exact 16-byte boundary must also +succeed. Dedicated first-byte/inactivity cases retain their 30ms guards and typed assertions. +Fresh remote CI must execute these cases; no local suite or checker is allowed. + +Independent plan audit: Kant PASS. The response delay is deliberate fault injection, not +sleep-based readiness synchronization. No production guard, assertion, timeout-focused +case, retry policy, skip, dependency or workflow is removed or weakened. +Independent implementation review: Kant PASS after inspecting the concrete three-file delta; +remote execution is still required before declaring the correction verified. diff --git a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md index b0e9c674c5..5c676ea3f9 100644 --- a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md +++ b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md @@ -64,4 +64,14 @@ fetch shim intact; keep only this unit's additional HTTPS-schema rejection and p privacy assertions there. Preserve upstream eager-relay cancellation changes verbatim. Do not replace the new cross-platform fixture with the superseded polling fixture. +CI repair scope: the output-byte case in `tests/lab/lab-live-pinned-timeouts.test.ts` +inherits 30ms first-byte/inactivity deadlines from neighboring timeout tests. The macOS +failure reached `first_byte_timeout` before the byte guard. Give only this size case 1000ms +first-byte/inactivity budgets, keeping the 128-byte response, 16-byte ceiling and exact +`output_byte_limit` assertion. Inject the same 150ms response delay used by the neighboring +timeout case so restoring the old 30ms budget deterministically preempts the intended guard. +Keep both dedicated timeout tests and all production limits unchanged. Add an exact-16-byte +success boundary under the size-case budgets. Record hypotheses and remote red/green evidence +in 013; no local validation, skip, retry policy or CI workflow change is permitted. + CLI GitHub reads are bounded, at most one fresh rollup per meaningful head/state change. Capture C receipt using the exact-head CI verification command. DONE only with all ancestry proofs; wait for pending CI using bounded polling, never call pending CI a blocker. diff --git a/tests/lab/lab-live-pinned-timeouts.test.ts b/tests/lab/lab-live-pinned-timeouts.test.ts index 91ad154297..a3a4757c9c 100644 --- a/tests/lab/lab-live-pinned-timeouts.test.ts +++ b/tests/lab/lab-live-pinned-timeouts.test.ts @@ -105,13 +105,38 @@ describe("CL-03 pinned live transport failure classification", () => { test("preserves the output byte ceiling as output_byte_limit", async () => { const port = await listen((_req, res) => { - res.writeHead(200, { "content-type": "text/plain" }); - res.end("x".repeat(128)); + // Fault injection: the neighboring 30ms timeout fixture must not decide + // this byte-limit case before its oversized response can arrive. + setTimeout(() => { + if (res.destroyed) return; + res.writeHead(200, { "content-type": "text/plain" }); + res.end("x".repeat(128)); + }, 150); }); - await expect(send(port, { maxOutputBytes: 16 })).rejects.toMatchObject({ + await expect(send(port, { + maxOutputBytes: 16, + firstByteTimeoutMs: 1_000, + inactivityTimeoutMs: 1_000, + })).rejects.toMatchObject({ name: "TransportError", code: "output_byte_limit", }); }); + + test("allows a response exactly at the output byte ceiling", async () => { + const port = await listen((_req, res) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("x".repeat(16)); + }); + + await expect(send(port, { + maxOutputBytes: 16, + firstByteTimeoutMs: 1_000, + inactivityTimeoutMs: 1_000, + })).resolves.toMatchObject({ + status: 200, + body: "x".repeat(16), + }); + }); }); From 2ea9ba7df4a089221ac4d5116521d3cb2b56d4b0 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 15:22:59 +0900 Subject: [PATCH 07/10] fix(diagnostics): preserve bounded launcher failure evidence --- .../040_stack_landing.md | 24 +- .../content/docs/reference/cli/lifecycle.md | 3 + src/codex/shim.ts | 64 ++- structure/01_runtime.md | 5 + tests/codex-integration/codex-shim.test.ts | 43 ++ tests/update/update-stop-first.test.ts | 380 +++++++++++++++++- 6 files changed, 486 insertions(+), 33 deletions(-) diff --git a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md index ab0c7b1405..66d88dca00 100644 --- a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md +++ b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md @@ -1,6 +1,28 @@ # Verified bottom-up stack landing -Depends on all implementation layers. Execute as `landing`; no production patch planned. +## Authorized continuation + +The user explicitly extended this goal to the CI-blocking launcher, shim and process +failures and authorized completion without further routine scope pauses. Existing no-local- +suite/typecheck/build/lint/scan restrictions remain. Verification is remote CI only; commits +and pushes use no-verify. The live proxy, user accounts and usage history remain untouched. + +Replan the unfinished landing cycle; no prior failed check is marked successful. First improve +bounded diagnostic classification in `src/codex/shim.ts` and its existing integration test, +and in `tests/update/update-stop-first.test.ts`. Keep unknown outcomes fail-closed. Do not +raise production deadlines, accept live descendants, suppress assertions, expose raw child +output or add retry-to-green behavior. Detailed diagnostic hypotheses and the write map live +in ignored scratch space. Then repair only causes established by remote evidence, with an +independent security/implementation review before publishing each dependent cascade. + +Main owns shim outcome diagnostics and its test; the delegated update worker owns only the +update-recovery fixture and its bounded tests. No worker may modify Git, CI state, the goal, +another worker's files or run local validation. New production fixes beyond diagnostics are +amended here and independently audited before writing. Allow up to 90 minutes of active +work for this authorized repair pass; exclude external CI queue time from active work. + +Depends on all implementation layers. Execute as `landing`; production changes are limited +to the authorized, reviewed CI-blocking diagnostics and evidence-backed corrections. Inherit resource/scope limits from 000. User explicitly authorizes no-verify pushes and admin merges only after CI succeeds. ## Actions diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 93c6fa63f3..0e91777061 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -370,6 +370,9 @@ service startup is bypassed. It refuses the change and rolls back when the launc cannot be validated and cleaned up safely. Therefore `codex-shim install` is not unconditional. If it is refused, reinstall Codex so the PATH entry is a concrete executable or launcher and retry; use `ocx service install` instead when a dynamic command-manager launcher cannot meet these checks. +Cleanup refusals include a bounded diagnostic suffix identifying the probe phase, a recognized +native error code or signal, and the exit status when known. It does not include launcher paths +or raw child output, and does not relax the validation or rollback checks. During upgrades, an installed Unix shim that lacks the current validation guard is regenerated and probed. If its saved launcher is unsafe, OpenCodex removes the obsolete shim and restores the original launcher instead of leaving the unsafe wrapper installed. diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 5d64d0dbbe..f53f778b12 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -766,7 +766,42 @@ exec ${shQuote(realCodexPath)} "$@" `; } -type UnixShimProbeResult = "cleanup" | "descendants" | "failed" | "recursive" | "timeout" | null; +type UnixShimProbeCleanupPhase = "marker" | "reentry" | "group" | "stderr" | "group-id" | "termination" | "spawn" | "exception"; +interface UnixShimProbeCleanup { + kind: "cleanup"; + phase: UnixShimProbeCleanupPhase; + code: string; + status: number | null; + signal: string; +} +type UnixShimProbeResult = UnixShimProbeCleanup | "descendants" | "failed" | "recursive" | "timeout" | null; + +const SHIM_PROBE_ERROR_CODES = new Set([ + "EACCES", "EAGAIN", "EBADF", "ECANCELED", "EINTR", "EIO", "EMFILE", "ENFILE", + "ENOENT", "ENOEXEC", "ENOMEM", "ENOSPC", "EPERM", "EPIPE", "ESRCH", "ETIMEDOUT", "ETXTBSY", +]); +const SHIM_PROBE_SIGNALS = new Set([ + "SIGABRT", "SIGBUS", "SIGHUP", "SIGILL", "SIGINT", "SIGKILL", "SIGPIPE", "SIGQUIT", + "SIGSEGV", "SIGTERM", "SIGTRAP", "SIGXCPU", "SIGXFSZ", +]); + +/** Diagnostics cross a CLI boundary: never stringify arbitrary errors or metadata. */ +function shimProbeCleanup( + phase: UnixShimProbeCleanupPhase, error?: unknown, status?: unknown, signal?: unknown, +): UnixShimProbeCleanup { + let code = error === undefined ? "none" : "unknown"; + if (error !== null && typeof error === "object") { + try { + const value = Object.getOwnPropertyDescriptor(error, "code")?.value; + if (typeof value === "string" && SHIM_PROBE_ERROR_CODES.has(value)) code = value; + } catch { /* hostile accessors/proxies cannot turn diagnostics into an exception */ } + } + return { + kind: "cleanup", phase, code, + status: typeof status === "number" && Number.isInteger(status) && status >= 0 && status <= 255 ? status : null, + signal: typeof signal === "string" && SHIM_PROBE_SIGNALS.has(signal) ? signal : "none", + }; +} let codexShimProbeHookForTests: (() => void) | null = null; let codexShimProbeShellForTests: string | null = null; @@ -842,6 +877,8 @@ function probeUnixShimInstall(wrapperPath: string): UnixShimProbeResult { delete env.OCX_SHIM_ACTIVE_DEPTH; delete env.OCX_SHIM_PROBE_ACTIVE; let groupId = 0; + let probeStatus: unknown; + let probeSignal: unknown; try { chmodSync(probeDir, 0o700); const result = spawnSync(process.execPath, [ @@ -863,26 +900,31 @@ function probeUnixShimInstall(wrapperPath: string): UnixShimProbeResult { timeout: CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS + CODEX_SHIM_INSTALL_PROBE_EXIT_TIMEOUT_MS, killSignal: "SIGKILL", }); + probeStatus = result.status; + probeSignal = result.signal; const timedOut = (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT"; const marker = readProbeMetadata(markerPath, 64); const reentryMarker = readProbeMetadata(reentryPath, 64); const groupText = readProbeMetadata(groupPath, 64); const launcherStderr = readProbeMetadata(stderrPath, MAX_DIAGNOSTIC_VALUE_BYTES); groupId = groupText === null ? 0 : Number.parseInt(groupText, 10); - if (marker === null || reentryMarker === null || groupText === null || launcherStderr === null - || !Number.isInteger(groupId) || groupId <= 0) return "cleanup"; + if (marker === null) return shimProbeCleanup("marker", result.error, probeStatus, probeSignal); + if (reentryMarker === null) return shimProbeCleanup("reentry", result.error, probeStatus, probeSignal); + if (groupText === null) return shimProbeCleanup("group", result.error, probeStatus, probeSignal); + if (launcherStderr === null) return shimProbeCleanup("stderr", result.error, probeStatus, probeSignal); + if (!Number.isInteger(groupId) || groupId <= 0) return shimProbeCleanup("group-id", result.error, probeStatus, probeSignal); const groupSurvived = unixProcessGroupAlive(groupId); if (timedOut || marker || reentryMarker || groupSurvived) { try { terminateUnixProcessGroup(groupId); - } catch { - return "cleanup"; + } catch (error) { + return shimProbeCleanup("termination", error, probeStatus, probeSignal); } } - if (result.error && !timedOut) return "cleanup"; + if (result.error && !timedOut) return shimProbeCleanup("spawn", result.error, probeStatus, probeSignal); if (timedOut || marker === "timeout") return "timeout"; if (marker === "recursive" || reentryMarker === "recursive") return "recursive"; - if (reentryMarker !== "") return "cleanup"; + if (reentryMarker !== "") return shimProbeCleanup("reentry", undefined, probeStatus, probeSignal); if (marker === "descendants") return "descendants"; if (groupSurvived) return "descendants"; if (result.status === CODEX_SHIM_REENTRY_EXIT_CODE && launcherStderr.includes(CODEX_SHIM_REENTRY_DIAGNOSTIC)) { @@ -890,11 +932,11 @@ function probeUnixShimInstall(wrapperPath: string): UnixShimProbeResult { } if (result.status !== 0) return "failed"; return null; - } catch { + } catch (error) { if (Number.isInteger(groupId) && groupId > 0) { try { terminateUnixProcessGroup(groupId); } catch { /* cleanup classification below */ } } - return "cleanup"; + return shimProbeCleanup("exception", error, probeStatus, probeSignal); } finally { try { rmSync(probeDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } } @@ -2215,8 +2257,8 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i ? `the saved launcher did not finish --version within ${CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS}ms` : unsafe === "descendants" ? "the saved launcher left background descendants running after --version" - : unsafe === "cleanup" - ? "the saved launcher's probe process group could not be terminated cleanly" + : unsafe !== null && typeof unsafe === "object" + ? `the saved launcher's probe process group could not be terminated cleanly [phase=${unsafe.phase}; code=${unsafe.code}; status=${unsafe.status ?? "none"}; signal=${unsafe.signal}]` : "the saved launcher failed its --version probe"; return { installed: false, diff --git a/structure/01_runtime.md b/structure/01_runtime.md index 7a5139cfad..3af3265c61 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -97,6 +97,11 @@ tracked sibling before mutation and rolls back earlier siblings in reverse order Failures warn without changing the requested command's exit behavior. The probe uses read-only config diagnostics only for a confirmed candidate and never reads adjacent auth state. +Unix install-probe cleanup refusals retain their fail-closed behavior and report a bounded +diagnostic suffix: a fixed probe phase, allowlisted native error/signal, and bounded exit status. +Metadata contents, launcher paths and raw child errors never enter that suffix. Diagnostic +classification does not grant process ownership or change rollback/termination policy. + Codex CLI update inspection is split from mutation. `system codex-cli-update check` makes no package-registry request and reads bounded provenance evidence for the configured launcher candidate, npm ownership layout, package metadata, and shim binding. The proof-bound launcher snapshot does not attest successful Codex execution; diff --git a/tests/codex-integration/codex-shim.test.ts b/tests/codex-integration/codex-shim.test.ts index 7094eb3748..f413fbdffb 100644 --- a/tests/codex-integration/codex-shim.test.ts +++ b/tests/codex-integration/codex-shim.test.ts @@ -498,6 +498,8 @@ exit 126 const installed = installCodexShim(); expect(installed.installed).toBe(false); + expect(installed.message).not.toContain(binDir); + expect(installed.message).not.toContain(home); expect(readFileSync(codexPath, "utf8")).toBe(original); expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); } finally { @@ -511,6 +513,47 @@ exit 126 } }); + test("Unix install reports a closed metadata phase without echoing probe content", () => { + if (process.platform === "win32") return; + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-diagnostic-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-diagnostic-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const shellPath = join(binDir, "synthetic-sensitive-shell-path"); + const original = successfulLauncher("diagnostic-original"); + const rejectedDetail = "synthetic-sensitive-probe-detail".repeat(4); + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, "utf8"); + chmodSync(codexPath, 0o755); + // No descendants: only invalidate bounded probe metadata, then exit. + writeFileSync(shellPath, `#!/bin/sh\nprintf '%s' '${rejectedDetail}' > "$OCX_SHIM_PROBE_REENTRY_PATH"\n`, "utf8"); + chmodSync(shellPath, 0o755); + setCodexShimProbeShellForTests(shellPath); + + const installed = installCodexShim(); + expect(installed.installed).toBe(false); + expect(installed.message).toContain("probe process group could not be terminated cleanly"); + expect(installed.message).toContain("[phase=reentry; code=none;"); + expect(installed.message).not.toContain(rejectedDetail); + expect(installed.message).not.toContain(shellPath); + expect(installed.message).not.toContain(home); + expect(readFileSync(codexPath, "utf8")).toBe(original); + expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } finally { + setCodexShimProbeShellForTests(null); + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(binDir); + removeTreeWithRetry(home); + } + }); + test.skipIf(process.platform === "win32" || !existsSync("/usr/bin/true"))( "Unix install probes a concrete native executable through the generated wrapper", () => { diff --git a/tests/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts index e3f4de9df5..9a9f5db268 100644 --- a/tests/update/update-stop-first.test.ts +++ b/tests/update/update-stop-first.test.ts @@ -60,7 +60,7 @@ async function waitForProxy(port: number, onFailure: (lastProbe: string) => void if (response.ok) return true; } catch (error) { // Error messages can contain URLs/credentials. Report only fixed error categories. - lastProbe = diagnosticCategories(error instanceof Error ? `${error.name} ${error.message}` : ""); + lastProbe = JSON.stringify(recoveryErrorFields(error)); } // The detached process exposes readiness only over HTTP; fake timers cannot advance it. await Bun.sleep(100); @@ -69,9 +69,97 @@ async function waitForProxy(port: number, onFailure: (lastProbe: string) => void return false; } +const RECOVERY_ERROR_CODES = new Set([ + "ENOENT", "EACCES", "EPERM", "ESRCH", "EADDRINUSE", "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", + "EAGAIN", "ENOMEM", "EMFILE", "ENFILE", "ENOSPC", "ENOEXEC", "EIO", "ETXTBSY", "EPIPE", + "ERR_MODULE_NOT_FOUND", "ERR_DLOPEN_FAILED", "ERR_WORKER_INIT_FAILED", "ERR_SYSTEM_ERROR", +]); +const RECOVERY_ERROR_NAMES = new Set(["Error", "AbortError", "TimeoutError", "TypeError", "SyntaxError", "ReferenceError", "RangeError"]); +const RECOVERY_SIGNALS = new Set(["SIGINT", "SIGTERM", "SIGHUP", "SIGKILL", "SIGABRT", "SIGSEGV", "SIGBUS", "SIGILL", "SIGPIPE", "SIGQUIT", "SIGTRAP"]); +const RECOVERY_EVENTS = new Set([ + "launcher-start", "launcher-exit", "boot-restore-enter", "boot-restore-result", "boot-restore-error", + "runtime-resolution-enter", "runtime-resolved", "runtime-install-enter", "runtime-install-result", + "runtime-spawn-call", "runtime-spawned", "runtime-spawn-error", "runtime-exit", +]); + +function recoveryOwnData(value: unknown, key: string): unknown { + try { + if (value === null || (typeof value !== "object" && typeof value !== "function")) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; + } catch { return undefined; } +} + +function recoveryErrorFields(error: unknown): { errorName: string; code: string; causeCode: string } { + let name = recoveryOwnData(error, "name"); + if (name === undefined && error !== null && typeof error === "object") { + try { name = recoveryOwnData(Object.getPrototypeOf(error), "name"); } catch { /* unknown */ } + } + const code = recoveryOwnData(error, "code"); + const causeCode = recoveryOwnData(recoveryOwnData(error, "cause"), "code"); + return { + errorName: typeof name === "string" && RECOVERY_ERROR_NAMES.has(name) ? name : "unknown", + code: typeof code === "string" && RECOVERY_ERROR_CODES.has(code) ? code : "unknown", + causeCode: typeof causeCode === "string" && RECOVERY_ERROR_CODES.has(causeCode) ? causeCode : "unknown", + }; +} + +function recoveryStatusRecord(raw: unknown): Record | null { + const event = recoveryOwnData(raw, "event"); + if (recoveryOwnData(raw, "v") !== 1 || typeof event !== "string" || !RECOVERY_EVENTS.has(event)) return null; + const out: Record = { v: 1, event }; + if (event === "launcher-start" || event === "runtime-spawned") { + const pid = recoveryOwnData(raw, "pid"); + out.pid = typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0 ? pid : "unknown"; + } + if (event === "runtime-resolved") { + const source = recoveryOwnData(raw, "source"); + out.source = source === "override" || source === "bundled" ? source : "unknown"; + } + if (event === "boot-restore-result") { + const action = recoveryOwnData(raw, "action"); + out.action = action === "restored" || action === "failed" || action === "none" || action === "reaped" ? action : "unknown"; + } + if (event === "launcher-exit" || event === "runtime-exit" || event === "runtime-install-result") { + const code = recoveryOwnData(raw, "exitCode"); + const signal = recoveryOwnData(raw, "signal"); + out.exitCode = code === null || (typeof code === "number" && Number.isInteger(code) && code >= 0 && code <= 255) ? code : "unknown"; + out.signal = signal === null || (typeof signal === "string" && RECOVERY_SIGNALS.has(signal)) ? signal : "unknown"; + } + if (event === "runtime-spawn-error" || event === "boot-restore-error" || event === "runtime-install-result") { + for (const key of ["errorName", "code", "causeCode"]) { + const value = recoveryOwnData(raw, key); + const allowed = key === "errorName" ? RECOVERY_ERROR_NAMES : RECOVERY_ERROR_CODES; + out[key] = typeof value === "string" && allowed.has(value) ? value : "unknown"; + } + } + return out; +} + function diagnosticCategories(text: string): string { - const matches = text.match(/\b(?:ENOENT|EACCES|EPERM|EADDRINUSE|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ERR_MODULE_NOT_FOUND|AbortError|TimeoutError|TypeError|SyntaxError|ReferenceError|RangeError|Cannot find package|Cannot find module|Failed to resolve|ConnectionRefused|FailedToOpenSocket)\b/g); - return [...new Set(matches ?? [])].join(", ") || "unclassified (text redacted)"; + const matches = text.match(/\b(?:ENOENT|EACCES|EPERM|ESRCH|EADDRINUSE|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAGAIN|ENOMEM|EMFILE|ENFILE|ENOSPC|ENOEXEC|EIO|ETXTBSY|EPIPE|ERR_MODULE_NOT_FOUND|ERR_DLOPEN_FAILED|ERR_WORKER_INIT_FAILED|ERR_SYSTEM_ERROR|AbortError|TimeoutError|TypeError|SyntaxError|ReferenceError|RangeError|Cannot find package|Cannot find module|Failed to resolve|ConnectionRefused|FailedToOpenSocket)\b/g); + const categories = new Set(matches ?? []); + if (/out of memory|cannot allocate memory|allocation failed/i.test(text)) categories.add("allocation-failure"); + if (/dyld\[|library not loaded|symbol not found/i.test(text)) categories.add("native-loader-failure"); + if (/segmentation fault|bus error|illegal instruction|panic:/i.test(text)) categories.add("native-runtime-failure"); + return [...categories].join(", ") || "unclassified (text redacted)"; +} + +function recoveryStatusRecords(text: string): Array> { + const records: Array> = []; + for (const line of text.slice(-8192).split("\n")) { + try { + const record = recoveryStatusRecord(JSON.parse(line)); + if (record) records.push(record); + } catch { /* malformed/torn records are not evidence */ } + } + return records.slice(-16); +} + +function recoveryLiveness(pid: unknown, probe: (pid: number) => unknown = pid => process.kill(pid, 0)): string { + if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return "unrecorded"; + try { probe(pid); return "alive"; } + catch (error) { return recoveryOwnData(error, "code") === "ESRCH" ? "absent" : "unknown"; } } // Read at most 8 KiB even if a broken child logs continuously. Never emit raw output: @@ -85,8 +173,11 @@ function recoveryDiagnosticFile(path: string, status = false): string { const count = readSync(fd, bytes, 0, bytes.length, Math.max(0, size - bytes.length)); const text = bytes.subarray(0, count).toString("utf8"); if (status) { - // This file contains fixture-generated records only. Still allowlist every field. - return text.split("\n").filter(line => /^(?:launcher-start pid=\d+|launcher-exit code=\d+|runtime-exit code=(?:null|\d+) signal=(?:null|SIG[A-Z0-9]+)|runtime-spawn-error)$/.test(line)).slice(-6).join("; ") || "no exit record"; + const records = recoveryStatusRecords(text); + return JSON.stringify({ records, liveness: { + launcher: recoveryLiveness(records.findLast(row => row.event === "launcher-start")?.pid), + runtime: recoveryLiveness(records.findLast(row => row.event === "runtime-spawned")?.pid), + } }); } const frames = [...text.matchAll(/\b(src\/[\w./-]+\.(?:ts|mjs))(?::(\d+)(?::(\d+))?)?/g)] .filter(match => !match[1]!.includes("..") && existsSync(join(repoRoot, match[1]!))) @@ -99,21 +190,27 @@ function recoveryDiagnosticFile(path: string, status = false): string { } } -function instrumentRecoveryLauncher(source: string, directory: string): string { - // Fail closed on launcher drift: never silently run an uninstrumented fixture or - // alter another spawn. Production bin/ocx.mjs and all real lifecycle code stay intact. - const replaceOnce = (needle: string, replacement: string) => { - if (source.split(needle).length !== 2) throw new Error("recovery diagnostic fixture: launcher seam changed"); - source = source.replace(needle, () => replacement); - }; - replaceOnce('import { spawn, spawnSync } from "node:child_process";', ` +function recoveryInstrumentationPrelude(directory: string): string { + // Reuse exactly the projector exercised by the in-process redaction tests. + return ` import { spawn as fixtureSpawn, spawnSync } from "node:child_process"; import { openSync as fixtureOpen, closeSync as fixtureClose, appendFileSync as fixtureAppend } from "node:fs"; const fixtureDiagnosticDir = ${JSON.stringify(directory)}; -function fixtureStatus(record) { - if (process.argv[2] !== "start") return; +const RECOVERY_ERROR_CODES = new Set(${JSON.stringify([...RECOVERY_ERROR_CODES])}); +const RECOVERY_ERROR_NAMES = new Set(${JSON.stringify([...RECOVERY_ERROR_NAMES])}); +const RECOVERY_SIGNALS = new Set(${JSON.stringify([...RECOVERY_SIGNALS])}); +const RECOVERY_EVENTS = new Set(${JSON.stringify([...RECOVERY_EVENTS])}); +${recoveryOwnData.toString()} +${recoveryErrorFields.toString()} +${recoveryStatusRecord.toString()} +let fixtureStatusCount = 0; +function fixtureStatus(event, fields = {}) { + if (process.argv[2] !== "start" || fixtureStatusCount >= 16) return; try { - fixtureAppend(fixtureDiagnosticDir + "/status", record + "\\n", { mode: 0o600 }); + const record = recoveryStatusRecord({ ...fields, v: 1, event }); + if (!record) return; + fixtureStatusCount += 1; + fixtureAppend(fixtureDiagnosticDir + "/status", JSON.stringify(record) + "\\n", { mode: 0o600 }); } catch { /* diagnostics must not interrupt the real exit/signal handler or teardown */ } } function spawn(bin, args, options) { @@ -128,15 +225,33 @@ function spawn(bin, args, options) { if (stderr !== undefined) fixtureClose(stderr); } } -fixtureStatus("launcher-start pid=" + process.pid); -process.on("exit", code => fixtureStatus("launcher-exit code=" + code)); -`); +fixtureStatus("launcher-start", { pid: process.pid }); +process.on("exit", code => fixtureStatus("launcher-exit", { exitCode: code, signal: null })); +`; +} + +function instrumentRecoveryLauncher(source: string, directory: string): string { + // Fail closed on drift, preserving the real calls and every original handler. + const replaceOnce = (needle: string, replacement: string) => { + if (source.split(needle).length !== 2) throw new Error("recovery diagnostic fixture: launcher seam changed"); + source = source.replace(needle, () => replacement); + }; + replaceOnce('import { spawn, spawnSync } from "node:child_process";', recoveryInstrumentationPrelude(directory)); + const boot = 'const probe = bootRestoreProbe(resolve(here, ".."));'; + replaceOnce(boot, `fixtureStatus("boot-restore-enter");\n ${boot}\n fixtureStatus("boot-restore-result", { action: probe.action });`); + replaceOnce('} catch { /* the probe must never block launch */ }', '} catch (error) { fixtureStatus("boot-restore-error", recoveryErrorFields(error)); /* the probe must never block launch */ }'); + const runtime = 'const bunRuntime = resolveBun({ allowInstall: !codexCliUpdateInspection });'; + replaceOnce(runtime, `fixtureStatus("runtime-resolution-enter");\n${runtime}\nfixtureStatus("runtime-resolved", { source: bunRuntime.source });`); + const install = 'const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" });'; + replaceOnce(install, `fixtureStatus("runtime-install-enter");\n ${install}\n fixtureStatus("runtime-install-result", { exitCode: r.status, signal: r.signal, ...recoveryErrorFields(r.error) });`); + replaceOnce('const child = spawn(bun,', 'fixtureStatus("runtime-spawn-call");\nconst child = spawn(bun,'); // The updater exits before its detached child, so observe the Bun child from the // recovery launcher itself, BEFORE the existing handler mirrors its exit/signal. replaceOnce('child.on("exit", (code, signal) => {', `child.on("exit", (code, signal) => { - fixtureStatus("runtime-exit code=" + code + " signal=" + signal);`); + fixtureStatus("runtime-exit", { exitCode: code, signal });`); replaceOnce('child.on("error", err => {', `child.on("error", err => { - fixtureStatus("runtime-spawn-error");`); + fixtureStatus("runtime-spawn-error", recoveryErrorFields(err));`); + replaceOnce('const clearHandlers = () => {', 'child.on("spawn", () => fixtureStatus("runtime-spawned", { pid: child.pid }));\nconst clearHandlers = () => {'); return source; } const updateSource = readFileSync(join(repoRoot, "src", "update", "index.ts"), "utf8"); @@ -144,6 +259,229 @@ const launcherSource = readFileSync(join(repoRoot, "bin", "ocx.mjs"), "utf8"); const serverSource = readFileSync(join(repoRoot, "src", "server", "index.ts"), "utf8"); const dispatchSource = readFileSync(join(repoRoot, "src", "cli", "dispatch.ts"), "utf8"); +describe("bounded recovery diagnostics", () => { + test("structured codes preserve resource causes without messages, paths or getter execution", () => { + const cause = { code: "EMFILE", path: "/Users/private/credential" }; + const error = Object.assign(new TypeError("https://secret.invalid/bearer?token=private"), { code: "EAGAIN", cause }); + expect(recoveryErrorFields(error)).toEqual({ errorName: "TypeError", code: "EAGAIN", causeCode: "EMFILE" }); + expect(recoveryErrorFields({ name: "secret", code: "ERR_SECRET_TOKEN", cause: { code: "private" } })) + .toEqual({ errorName: "unknown", code: "unknown", causeCode: "unknown" }); + let getterCalls = 0; + const getters = Object.defineProperties({}, Object.fromEntries(["name", "message", "code", "cause", "stack"].map(key => [key, { + get() { getterCalls += 1; throw new Error("must not read getters"); }, + }]))); + expect(recoveryErrorFields(getters)).toEqual({ errorName: "unknown", code: "unknown", causeCode: "unknown" }); + expect(getterCalls).toBe(0); + const cyclic = { name: "Error", code: "ENOMEM", cause: undefined as unknown }; + cyclic.cause = cyclic; + expect(recoveryErrorFields(cyclic)).toEqual({ errorName: "Error", code: "ENOMEM", causeCode: "ENOMEM" }); + expect(recoveryErrorFields(null)).toEqual({ errorName: "unknown", code: "unknown", causeCode: "unknown" }); + }); + + test("status projects only event-specific fields and rejects forged schemas", () => { + expect(recoveryStatusRecord({ v: 1, event: "runtime-resolved", source: "bundled", path: "/Users/private", token: "secret", pid: 12 })) + .toEqual({ v: 1, event: "runtime-resolved", source: "bundled" }); + expect(recoveryStatusRecord({ v: 1, event: "runtime-exit", exitCode: 7, signal: null, stack: "secret" })) + .toEqual({ v: 1, event: "runtime-exit", exitCode: 7, signal: null }); + expect(recoveryStatusRecord({ v: 1, event: "runtime-exit", exitCode: -1, signal: "SIG_SECRET" })) + .toEqual({ v: 1, event: "runtime-exit", exitCode: "unknown", signal: "unknown" }); + expect(recoveryStatusRecord({ v: 1, event: "launcher-start", pid: "123 /private" })) + .toEqual({ v: 1, event: "launcher-start", pid: "unknown" }); + expect(recoveryStatusRecord({ v: 2, event: "runtime-exit" })).toBeNull(); + expect(recoveryStatusRecord({ v: 1, event: "secret" })).toBeNull(); + const many = Array.from({ length: 30 }, () => JSON.stringify({ v: 1, event: "runtime-spawn-call", token: "secret" })).join("\n"); + const records = recoveryStatusRecords(`${many}\nnot-json\n{"v":1`); + expect(records).toHaveLength(16); + for (const record of records) expect(record).toEqual({ v: 1, event: "runtime-spawn-call" }); + expect(JSON.stringify(records)).not.toContain("secret"); + }); + + test("bounded stderr summaries classify native/resource failures but never expose arbitrary text", () => { + const directory = mkdtempSync(join(tmpdir(), "ocx-recovery-redaction-")); + try { + const path = join(directory, "stderr"); + writeFileSync(path, "hidden-prefix".repeat(1000) + "\nENOMEM dyld[123]: Library not loaded: /Users/private/token\npanic: bearer-secret@example.test\n"); + const summary = recoveryDiagnosticFile(path); + expect(summary).toContain("ENOMEM"); + expect(summary).toContain("native-loader-failure"); + expect(summary).toContain("native-runtime-failure"); + expect(summary).not.toContain("hidden-prefix"); + expect(summary).not.toContain("/Users/"); + expect(summary).not.toContain("bearer-secret"); + expect(summary).not.toContain("@"); + expect(summary.length).toBeLessThanOrEqual(1200); + expect(diagnosticCategories("220 bytes of unknown material: https://secret.invalid/token")) + .toBe("unclassified (text redacted)"); + const statusPath = join(directory, "status"); + writeFileSync(statusPath, JSON.stringify({ v: 1, event: "runtime-resolved", source: "bundled", path: "secret" }) + "\n"); + expect(JSON.parse(recoveryDiagnosticFile(statusPath, true))).toEqual({ + records: [{ v: 1, event: "runtime-resolved", source: "bundled" }], + liveness: { launcher: "unrecorded", runtime: "unrecorded" }, + }); + } finally { removeTreeWithRetry(directory); } + }); + + test("liveness distinguishes absent from inaccessible without sending termination signals", () => { + const calls: number[] = []; + expect(recoveryLiveness(123, pid => { calls.push(pid); })).toBe("alive"); + expect(calls).toEqual([123]); + expect(recoveryLiveness(123, () => { throw { code: "ESRCH" }; })).toBe("absent"); + expect(recoveryLiveness(123, () => { throw { code: "EPERM" }; })).toBe("unknown"); + expect(recoveryLiveness(undefined, () => { throw new Error("must not probe"); })).toBe("unrecorded"); + }); + + test("instrumentation fails closed if any selected launcher seam disappears or duplicates", () => { + const seams = [ + 'import { spawn, spawnSync } from "node:child_process";', + 'const probe = bootRestoreProbe(resolve(here, ".."));', + '} catch { /* the probe must never block launch */ }', + 'const bunRuntime = resolveBun({ allowInstall: !codexCliUpdateInspection });', + 'const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" });', + 'const child = spawn(bun,', 'child.on("exit", (code, signal) => {', + 'child.on("error", err => {', 'const clearHandlers = () => {', + ]; + for (const seam of seams) { + expect(() => instrumentRecoveryLauncher(launcherSource.replace(seam, ""), "/fixture")) + .toThrow("recovery diagnostic fixture: launcher seam changed"); + expect(() => instrumentRecoveryLauncher(`${launcherSource}\n${seam}`, "/fixture")) + .toThrow("recovery diagnostic fixture: launcher seam changed"); + } + }); + + test("generated capture wrapper preserves spawn options, return/error identity and FD finally", () => { + const calls: unknown[][] = []; + const closed: number[] = []; + const records: string[] = []; + let unrefs = 0; + const child = { unref: () => { unrefs += 1; } }; + const failure = new Error("fixture spawn failure"); + let failSpawn = false; + let failAppend = false; + let failSecondOpen = false; + let nextFd = 10; + // Evaluate ONLY the generated prelude with inert dependencies, never the real updater. + const prelude = recoveryInstrumentationPrelude("/fixture").replace(/^import .*;\n/gm, ""); + const fixture = new Function("fixtureSpawn", "fixtureOpen", "fixtureClose", "fixtureAppend", "process", + `${prelude}\nreturn { spawn, fixtureStatus };`)( + (...args: unknown[]) => { calls.push(args); if (failSpawn) throw failure; return child; }, + (_path: string, flags: string, mode: number) => { + expect([flags, mode]).toEqual(["a", 0o600]); + if (failSecondOpen && nextFd === 11) throw failure; + return nextFd++; + }, + (fd: number) => closed.push(fd), + (_path: string, record: string, options: { mode: number }) => { + expect(options).toEqual({ mode: 0o600 }); + if (failAppend) throw failure; + records.push(record); + }, + { argv: ["node", "fixture", "start"], pid: 123, on: () => {} }, + ) as { spawn: (bin: string, args: string[], options: Record) => typeof child; fixtureStatus: (event: string, fields?: object) => void }; + const args = ["launcher", "start", "--port", "1234"]; + const env = { FIXTURE: "unchanged" }; + const options = { detached: true, stdio: "ignore", windowsHide: true, env }; + expect(fixture.spawn("node", args, options)).toBe(child); + expect(calls[0]).toEqual(["node", args, { ...options, stdio: ["ignore", 10, 11] }]); + expect(calls[0][1]).toBe(args); + expect((calls[0][2] as { env: unknown }).env).toBe(env); + expect(options.stdio).toBe("ignore"); + expect(closed).toEqual([10, 11]); + expect(unrefs).toBe(0); + child.unref(); + expect(unrefs).toBe(1); + const ordinary = { stdio: "inherit", env }; + expect(fixture.spawn("bun", ["cli", "start"], ordinary)).toBe(child); + expect(calls[1][2]).toBe(ordinary); + expect(closed).toEqual([10, 11]); + failAppend = true; + expect(() => fixture.fixtureStatus("runtime-spawn-call")).not.toThrow(); + expect(fixture.spawn("bun", [], ordinary)).toBe(child); + failAppend = false; + for (let i = 0; i < 40; i++) fixture.fixtureStatus("runtime-resolved", { source: "bundled", token: "secret" }); + expect(records.length).toBeLessThanOrEqual(16); + expect(records.join("")).not.toContain("secret"); + failSpawn = true; + try { fixture.spawn("node", args, options); throw new Error("expected spawn failure"); } + catch (error) { expect(error).toBe(failure); } + expect(closed).toEqual([10, 11, 12, 13]); + failSpawn = false; + failSecondOpen = true; + nextFd = 10; + try { fixture.spawn("node", args, options); throw new Error("expected open failure"); } + catch (error) { expect(error).toBe(failure); } + expect(closed.at(-1)).toBe(10); + }); + + test("instrumented inert launcher records milestones without changing error/exit handlers", () => { + const inertSource = `import { spawn, spawnSync } from "node:child_process"; +try { + const probe = bootRestoreProbe(resolve(here, "..")); +} catch { /* the probe must never block launch */ } +function resolveBun() { + const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" }); + return { source: "bundled", path: "fixture-bun" }; +} +const bunRuntime = resolveBun({ allowInstall: !codexCliUpdateInspection }); +const bun = bunRuntime.path; +const child = spawn(bun, ["fixture-cli", "start"], childOptions); +const clearHandlers = () => { events.push("clear"); }; +child.on("error", err => { clearHandlers(); events.push(err); process.exit(1); }); +child.on("exit", (code, signal) => { + clearHandlers(); + if (signal) { process.kill(process.pid, signal); return; } + process.exit(code ?? 1); +}); +return child;`; + const source = instrumentRecoveryLauncher(inertSource, "/fixture").replace(/^import .*;\n/gm, ""); + const records: string[] = []; + const events: unknown[] = []; + const handlers = new Map void>(); + const child = { pid: 456, on: (event: string, handler: (...args: unknown[]) => void) => handlers.set(event, handler) }; + const childOptions = { stdio: "inherit", env: { FIXTURE: "same" } }; + const io = { + fixtureSpawn: (bin: string, args: string[], options: unknown) => { + expect([bin, args]).toEqual(["fixture-bun", ["fixture-cli", "start"]]); + expect(options).toBe(childOptions); + return child; + }, + spawnSync: (bin: string, args: string[], options: unknown) => { + expect([bin, args, options]).toEqual(["node", ["fixture-install"], { stdio: "inherit" }]); + return { status: 0, signal: null }; + }, + fixtureOpen: () => { throw new Error("runtime stdio must stay inherited"); }, + fixtureClose: () => { throw new Error("no capture FD expected"); }, + fixtureAppend: (_path: string, record: string) => records.push(record), + process: { argv: ["node", "fixture", "start"], pid: 123, execPath: "node", on: () => {}, + exit: (code: number) => events.push(code), kill: (pid: number, signal: string) => events.push([pid, signal]) }, + bootRestoreProbe: () => ({ action: "none" }), resolve: () => "/fixture", here: "/fixture", + installJs: "fixture-install", codexCliUpdateInspection: false, childOptions, events, + }; + const execute = new Function("io", `const { ${Object.keys(io).join(", ")} } = io;\n${source}`); + expect(execute(io)).toBe(child); + handlers.get("spawn")!(); + handlers.get("exit")!(7, null); + expect(events).toEqual(["clear", 7]); + handlers.get("exit")!(null, "SIGTERM"); + expect(events.slice(-2)).toEqual(["clear", [123, "SIGTERM"]]); + const failure = Object.assign(new Error("private error text"), { code: "EAGAIN", cause: { code: "ENOMEM" } }); + handlers.get("error")!(failure); + expect(events.slice(-3)).toEqual(["clear", failure, 1]); + const decoded = recoveryStatusRecords(records.join("")); + expect(decoded.map(row => row.event)).toEqual([ + "launcher-start", "boot-restore-enter", "boot-restore-result", "runtime-resolution-enter", + "runtime-install-enter", "runtime-install-result", "runtime-resolved", "runtime-spawn-call", + "runtime-spawned", "runtime-exit", "runtime-exit", "runtime-spawn-error", + ]); + expect(decoded.at(-1)).toEqual({ v: 1, event: "runtime-spawn-error", errorName: "Error", code: "EAGAIN", causeCode: "ENOMEM" }); + expect(records.join("")).not.toContain("private error text"); + events.length = 0; + handlers.clear(); + expect(execute({ ...io, fixtureAppend: () => { throw new Error("diagnostic disk unavailable"); } })).toBe(child); + handlers.get("error")!(failure); + expect(events).toEqual(["clear", failure, 1]); + }); +}); + describe("update stops the running proxy before replacing files", () => { // The recovery case starts a real detached proxy, and its own result says nothing about // whether cleanup reaped it — it stayed green while an escapee spun on a deleted tree for From 0efd0c1594dfbcbf46002a2af38a269367619713 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 15:28:33 +0900 Subject: [PATCH 08/10] test(update): use explicit synthetic redaction paths --- tests/update/update-stop-first.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts index 9a9f5db268..cb02c25a6e 100644 --- a/tests/update/update-stop-first.test.ts +++ b/tests/update/update-stop-first.test.ts @@ -261,7 +261,7 @@ const dispatchSource = readFileSync(join(repoRoot, "src", "cli", "dispatch.ts"), describe("bounded recovery diagnostics", () => { test("structured codes preserve resource causes without messages, paths or getter execution", () => { - const cause = { code: "EMFILE", path: "/Users/private/credential" }; + const cause = { code: "EMFILE", path: "/synthetic-private/credential" }; const error = Object.assign(new TypeError("https://secret.invalid/bearer?token=private"), { code: "EAGAIN", cause }); expect(recoveryErrorFields(error)).toEqual({ errorName: "TypeError", code: "EAGAIN", causeCode: "EMFILE" }); expect(recoveryErrorFields({ name: "secret", code: "ERR_SECRET_TOKEN", cause: { code: "private" } })) @@ -279,7 +279,7 @@ describe("bounded recovery diagnostics", () => { }); test("status projects only event-specific fields and rejects forged schemas", () => { - expect(recoveryStatusRecord({ v: 1, event: "runtime-resolved", source: "bundled", path: "/Users/private", token: "secret", pid: 12 })) + expect(recoveryStatusRecord({ v: 1, event: "runtime-resolved", source: "bundled", path: "/synthetic-private", token: "secret", pid: 12 })) .toEqual({ v: 1, event: "runtime-resolved", source: "bundled" }); expect(recoveryStatusRecord({ v: 1, event: "runtime-exit", exitCode: 7, signal: null, stack: "secret" })) .toEqual({ v: 1, event: "runtime-exit", exitCode: 7, signal: null }); @@ -300,13 +300,13 @@ describe("bounded recovery diagnostics", () => { const directory = mkdtempSync(join(tmpdir(), "ocx-recovery-redaction-")); try { const path = join(directory, "stderr"); - writeFileSync(path, "hidden-prefix".repeat(1000) + "\nENOMEM dyld[123]: Library not loaded: /Users/private/token\npanic: bearer-secret@example.test\n"); + writeFileSync(path, "hidden-prefix".repeat(1000) + "\nENOMEM dyld[123]: Library not loaded: /synthetic-private/token\npanic: bearer-secret@example.test\n"); const summary = recoveryDiagnosticFile(path); expect(summary).toContain("ENOMEM"); expect(summary).toContain("native-loader-failure"); expect(summary).toContain("native-runtime-failure"); expect(summary).not.toContain("hidden-prefix"); - expect(summary).not.toContain("/Users/"); + expect(summary).not.toContain("/synthetic-private/"); expect(summary).not.toContain("bearer-secret"); expect(summary).not.toContain("@"); expect(summary.length).toBeLessThanOrEqual(1200); From 263fba9a3d8cbfbc81dc50b1bdadd385f5d26ed2 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 20:33:37 +0900 Subject: [PATCH 09/10] docs: finalize independent provider stack integration plan --- .../040_stack_landing.md | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md index 66d88dca00..fa5b423359 100644 --- a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md +++ b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md @@ -1,5 +1,54 @@ # Verified bottom-up stack landing +## Final integration pass + +The user ended cross-task CI coordination and instructed the remaining tasks to proceed +independently. Continue this stack without waiting for another task's START or sending it +messages. No local tests, typecheck, build, lint or scan; the previously authorized +no-verify pushes and CI-gated admin merges remain in effect. + +Class C4 integration verification, satisfy-spec loop. Consume the earlier verified quota +layers without redesigning them. Current published baseline is +`45f3bed84be10a7e045a20aae1db46ab822bf7d0`; this incorporates provider registration and +pending-selection contracts plus the upstream port-probe repair. Preserve those public +changes verbatim. The source delta in this pass is their actual merge into the bottom +branch, cascaded through API and UI, not a synthetic no-op edit. + +Exact change map: MODIFY this landing record; MERGE the published baseline into +`codex/provider-usage-attribution`; MERGE each new lower tip into +`codex/provider-account-quota-api` then `codex/provider-quota-parity`; MERGE the new UI tip +into `codex/provider-ci-isolation-followup`. The follow-up already contains reviewed +commit `b37841448816107c856171277dff0464032d282e`, limited to the update-recovery fixture +and its numbered record. Retain it as a fourth test-only stack layer. No new production +behavior is planned. Any semantic conflict requires a concrete plan amendment and review. + +Preserve all original commits; use normal merge commits and fast-forward no-verify pushes, +not rebases or force-pushes. Inspect each integration diff, check that inherited quota, +registration and pricing semantics survive, and obtain independent review before publication. +Every layer needs its own new full applicable GitHub CI, including the follow-up's actual +negative-inheritance and recovery scenarios. Old green trees are context, not final proof. + +Repository auto-deletion requires retargeting the direct child to dev BEFORE admin merging +its parent. Verify unchanged child head, then merge only the parent with +`--admin --merge --match-head-commit `. Fetch dev and prove both the merge +commit's ancestry and its tree match with the tested integration. Before each later merge, +refresh head/base/tree/reviews/checks; a changed integration tree needs fresh CI rather than +an old workflow rerun. Do not alter repository settings, other tasks' CI, live services or +user history. The final documentation record and archive must be published with their own +appropriate remote checks; no completion until all four layers and closure are on dev. + +Verifier: GitHub run/job output at the exact head and checkout tree (all required jobs +completed successfully), review-thread reads, `git diff`/`git merge-tree` for static +integration inspection only, and fetched `git merge-base --is-ancestor` for delivery. +No local executable verifier runs. Source/layout unchanged by a merge does not require +another render; any actual quota layout change requires a fresh observed isolated render. +The user-visible quota matrix and screenshots already recorded in031 remain required. +Terminal success is all original requirements plus follow-up and closure delivered, not +merely a clean textual merge. Preserve unknown historical usage and do not claim a fixed +historical stall without evidence. Active integration work is bounded to90minutes before +reassessment; queued remote CI time is excluded, and no new credential or spending authority +is introduced. + ## Authorized continuation The user explicitly extended this goal to the CI-blocking launcher, shim and process @@ -30,8 +79,8 @@ Inherit resource/scope limits from 000. User explicitly authorizes no-verify pus 1. Inspect `git status --short`, `git worktree list`, each branch tip and `gh pr view --json headRefOid,baseRefName,statusCheckRollup,reviewDecision,mergeStateStatus`. 2. Inspect exact-head CI via `gh run list --commit ` and failed job logs when necessary. An empty required-check list is not proof. Resolve correct review findings without suppressing tests. 3. Ensure every PR includes Summary, Verification and Checklist, a linked stack map, explicit no-local-suite note, and UI screenshot for UI changes. Record admin bypass authorization in the PR description. -4. Merge the bottom PR only when its exact head has successful full CI; prefer `gh pr merge --admin --merge --match-head-commit ` to preserve stack ancestry. Do not delete lower branches. -5. Retarget the next child to `dev`; refresh checks at its exact head/base. If ancestry reconstruction is necessary, use only session-owned branches with clean working state, record parent and child commits, cascade all upper layers and use `--force-with-lease --no-verify`; no destructive worktree operations. +4. After exact-head/integration-tree full CI, retarget the direct child to `dev` before its parent merges, because this repository automatically deletes merged remote heads. Preserve local lower refs. +5. Merge only the bottom PR with `gh pr merge --admin --merge --match-head-commit `; refresh the child's head/base/tree and checks. Reconstruct only session-owned branches with normal merges and no-verify fast-forward pushes; no destructive worktree operations. 6. After each merge, `git fetch origin dev` then `git merge-base --is-ancestor FETCH_HEAD`. Record PR, CI head, merge SHA and ancestry outcome in `041_delivery.md`. 7. Archive the completed unit from `_plan` to `_fin` only as an explicit final documented source change with its own remote checks if it alters a pending PR. Otherwise retain a terminal closure record without inventing extra unverified commits. From e7517bbbafd07a1b34303ac7cb7feb998f35b61b Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 20:53:26 +0900 Subject: [PATCH 10/10] docs: record final dev freshness requirement --- .../040_stack_landing.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md index fa5b423359..1d1426f687 100644 --- a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md +++ b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md @@ -2,6 +2,13 @@ ## Final integration pass +Pre-merge freshness update: all four45f3-based integration runs passed, but published +dev advanced to `09335d7d451335a74ad1c02e88ee37ef89f5a007` before landing. Its seven-file +delta is the upstream CLI status split, adjacent regression and documentation. Preserve +it verbatim through a normal merge and cascade, then require new exact-head/tree CI for +all four layers. No quota behavior or prior review fix is replaced, and no historical +passing run is relabeled as proof of the new integration tree. + The user ended cross-task CI coordination and instructed the remaining tasks to proceed independently. Continue this stack without waiting for another task's START or sending it messages. No local tests, typecheck, build, lint or scan; the previously authorized