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..c316a91814 --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/000_plan.md @@ -0,0 +1,73 @@ +# 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. + +Attribution D: `9a9ad98b8`, PR #3582, CI run33938837845 all four backend test shards, +GUI gates (1371 pass / 0 fail), API usage succeeded; independent Volta PASS. Remaining macOS +jobs are still mandatory at landing, not claimed green. Next quota-api P reverified 020 against +the unchanged quota/management baseline; attribution changes do not alter its signatures. +API layer branch `codex/provider-account-quota-api` starts at the verified attribution head. + +## Delegation write map (locked before Build) + +User reiterated no local suites during quota-api A. No local test suite has been run; +reported regression results came from GitHub Actions. From this point no further local +typecheck/build/lint/scan commands either: command-based validation is remote CI only. +Source inspection and browser observation remain scoped QA; receipts may only wrap remote +CI result checks. This supersedes the earlier local-static/build verification allowance. + +- 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..33c717f716 --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/002_audit_synthesis.md @@ -0,0 +1,25 @@ +# 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. + +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/010_attribution.md b/devlog/_plan/260905_provider_usage_quota_parity/010_attribution.md new file mode 100644 index 0000000000..ce3e59add3 --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/010_attribution.md @@ -0,0 +1,227 @@ +# 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. 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 + +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/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/012_premerge_review.md b/devlog/_plan/260905_provider_usage_quota_parity/012_premerge_review.md new file mode 100644 index 0000000000..6330d6695d --- /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 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. +- 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/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/020_account_quota_api.md b/devlog/_plan/260905_provider_usage_quota_parity/020_account_quota_api.md new file mode 100644 index 0000000000..d104d4544c --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/020_account_quota_api.md @@ -0,0 +1,608 @@ +# 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 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. + +## 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 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 + 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 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 + +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 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 | +| Unsupported force | Return mode only, no token resolution | + +`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 + +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 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 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. + +## 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 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 | +| 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: + +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 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. + +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 +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/021_api_build.md b/devlog/_plan/260905_provider_usage_quota_parity/021_api_build.md new file mode 100644 index 0000000000..ab6dfb30cd --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/021_api_build.md @@ -0,0 +1,46 @@ +# Account quota API build record + +Layer: `codex/provider-account-quota-api`, parent `9a9ad98b8` / PR #3582. +Main owns existing management route projection, route-level regression file and test-layout +registration. Euclid owns credential-scoped quota readers, key cache, shared fields and provider +regressions. No local test, typecheck, build, lint or scan commands run in this layer. + +Route projection now advertises capability on cheap lists, enriches only opt-in supported +credentials, preserves active selection, clears prior failure flags on success, and checks +private identity guards immediately before serializing safe quota fields. Passive reads remain +cache-only. Internal callbacks and credential identities are never spread into response DTOs. + +Kant's scoped route review found one missing activation test: replacing an environment key +inside fetch proves worker rejection but not the final route guard. Accepted and added a +completed non-null quota fixture with `isCurrent() === false`, one callback invocation and +null/unavailable JSON expectation. Same final-projection scenario added for OAuth rows. +Re-review of the key guard delta: PASS, static only. Full independent review and remote CI pending. + +C inspection at `979249852` found a new-cache age gap: a failed read refreshes attempt TTL +but preserves the measurement timestamp; a cache hit checked only attempt TTL and could keep +the measurement past the stated last-good age. Accepted root cause; repair only the new key +and explicit-OAuth cache hit paths, with deterministic clock boundary regressions. Existing +three OAuth reader cache behavior stays outside this repair. No local execution authorized. + +Remote CI run33939675289 at979249852 failed typecheck (`TS2322`, quota.ts1808): inferred +object return widened unique sentinel symbols to `symbol`. The valid translator-budget type +fixture failed downstream of that source compile error. Repair is an explicit return contract +for `readExplicitAccountQuota`, not changing or weakening the translator-budget regression. + +Independent Wegener review confirmed those two blockers and refined the age case: a failure +which settles after the last-good deadline must recheck measurement age after its await, +not just on the next cache hit. Accepted; both new cache paths gain post-await age checks and +an independently reachable delayed-failure clock fixture before this repair is republished. + +The same baseline run's test4/4 also exposed `Kimi quota skips usages when OAuth refresh fails`: +the new current-account dispatch returned an asynchronous reader without awaiting it inside its +catch boundary. Add the missing await so credential renewal rejection degrades to unavailable +as before; retain the existing regression unchanged. This is a separate runtime cause from +the type inference failure, not an assumed CI flake. + +Codex GitHub review3939190788 identified the inherited explicit-key-only Kimi guard as +inconsistent with omitted authMode's documented key default. Accepted for this feature: +the shared key selector's initial normalized auth guard already rejects OAuth/forward/local, +so Kimi now uses that same default and its canonical URL check. Added an omitted-mode +per-key regression plus forward-mode negative. This supersedes 020's preserve-stricter-Kimi +note, without adding destinations or sending OAuth credentials down the key path. 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..1d1426f687 --- /dev/null +++ b/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md @@ -0,0 +1,161 @@ +# Verified bottom-up stack landing + +## 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 +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 +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 + +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. 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. + +## 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 + +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. + +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. + +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, 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 +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. + +Upstream #3552 advanced `dev` to 9fe986d84 and conflicted in the same quota-observation +test. Preserve its main-account hard-lock and private quota-provenance implementation +verbatim, including monthly-primary semantics and exact quota-reset dispatch. Resolve only +the local snapshot variable name while retaining strict shape/equality and observation-time +bounds. No new hard-lock behavior is designed in this unit; fresh integrated CI is required. + +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/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 0000000000..b49e9d1815 Binary files /dev/null and b/devlog/_plan/260905_provider_usage_quota_parity/assets/011_usage_attribution.png differ diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 4639d0be98..92c1d786cc 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -101,6 +101,14 @@ Cost values in **Logs** and **Usage** are API list-price equivalents calculated They are not billing receipts or evidence of an actual charge; subscription usage or provider credits may apply instead. +Provider model rows may include **unresolved requested model usage**: the saved route sent the +requested name unchanged to the default provider. These tokens belong to that serving provider, +not necessarily the vendor named in the request. The dashboard preserves the original name and +usage rather than guessing which model ran. For slash-containing unresolved names, a different +vendor's model price alone is not enough to estimate cost; an exact provider or configured price +still applies. Model shares are calculated within the selected provider. Requests for an unknown +reserved `policy/` name now fail before reaching an upstream provider; historical usage is retained. + ## Model visibility The **Models** switches show final Codex visibility: a routed model is on only when its provider allowlist includes it (or no allowlist is set) and it is not disabled. Turning a model on reconciles both filters atomically; **All on** clears the provider allowlist so newly discovered models are also on. @@ -248,6 +256,7 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | `GET /api/models` · `PUT /api/disabled-models` | List native/routed model rows and update the shared disabled-model set. | | `GET /api/selected-models` · `PUT /api/model-visibility` | Read provider allowlists and atomically change the final visibility of one model or provider group. | | `GET /api/key-providers` · `GET /api/oauth/providers` | Read the API-key and OAuth provider catalogs. | +| `GET /api/oauth/accounts?provider=..."a=1` · `GET /api/providers/keys?name=..."a=1` | Read each account or key's quota where supported, without changing the active credential. Add `refresh=1` to bypass settled quota cache; an in-flight same-credential read can be shared. Omit `quota=1` for a cheap local list with each row's `quotaMode`: `probe`, `passive`, or `unsupported`. Passive reads return existing observations without a network probe. No reading is not the same as 0% used, and quotas for multiple keys are not summed. | | `POST /api/oauth/login` · `GET /api/oauth/status` | Start a provider OAuth flow and poll for completion. | | `GET /api/codex-auth/accounts?refresh=1` | List main and pool accounts, force quota refresh, and report main-account `hasCredential` / terminal `needsReauth` state. | | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | Select the account for the next request and configure pool routing. | 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/gui/src/components/provider-workspace/ProviderUsage.tsx b/gui/src/components/provider-workspace/ProviderUsage.tsx index 27aaadc14c..8fc802ba08 100644 --- a/gui/src/components/provider-workspace/ProviderUsage.tsx +++ b/gui/src/components/provider-workspace/ProviderUsage.tsx @@ -122,6 +122,9 @@ export default function ProviderUsage({ item, usageTotals, quotaReport, modelUsa > {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 2564bc9c38..a6ad7fee99 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, buildProviderUsageTotals, countAvailableModels, parseAvailableModels, parseLiveModelCounts, parseSelectedModels, type ProviderAvailableModels, type ProviderLiveModelCounts, type ProviderModelCounts, type ProviderSelectedModels } from "../../provider-workspace/usage"; import { freshQuotaReportRecord, freshQuotaReportsFromResponse, @@ -143,7 +143,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 ?? {} )); @@ -207,21 +207,15 @@ 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)); 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: 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 d57cd9850f..a196fd215a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1909,6 +1909,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 a5ac68bba8..88ffb02908 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1174,6 +1174,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 5774c57c90..14532847f1 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1147,6 +1147,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 6541b08d47..e347babd59 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2335,6 +2335,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 d4e3f44e99..29a937ffd7 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1936,6 +1936,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 044049398f..bfd2e086c3 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1158,6 +1158,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 1879d35ab3..c55fba1651 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1165,6 +1165,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 dfc2acebda..7f57a4d45f 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -958,6 +958,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 a79161cdba..0e04a2fb77 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1929,6 +1929,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..033bdbcee2 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,32 @@ 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 = Object.create(null); + 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..1820942572 --- /dev/null +++ b/gui/tests/provider-usage-attribution.test.tsx @@ -0,0 +1,65 @@ +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, buildProviderUsageTotals } 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("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 }, + { ...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/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2ecb36d56a..9bea518578 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -925,6 +925,7 @@ "process-state.test.ts": "service", "project-config-warnings.test.ts": "codex-integration", "provider-account-quota-persistence.test.ts": "providers", + "provider-account-quota-routes.test.ts": "server", "provider-account-quota.test.ts": "providers", "provider-api-keys.test.ts": "providers", "provider-capacity.test.ts": "providers", 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/src/providers/api-keys.ts b/src/providers/api-keys.ts index 6cf4c8e302..d138cdbf50 100644 --- a/src/providers/api-keys.ts +++ b/src/providers/api-keys.ts @@ -4,13 +4,14 @@ * `provider.apiKey` stays the single source of truth for routing — it always mirrors the * ACTIVE pool entry, so the router/adapters never learn about the pool. The pool itself * lives in `provider.apiKeyPool` in config.json (same file that already holds apiKey). - * A provider with a legacy bare `apiKey` is seeded into a one-entry pool on first touch. + * A legacy bare `apiKey` is projected as one row on reads and seeded on first mutation. */ import { createHash } from "node:crypto"; import { saveConfigPreservingClaudeCode } from "../config"; import type { OcxConfig, OcxProviderConfig } from "../types"; +import type { AccountQuotaFields } from "./quota-types"; -export interface ProviderApiKeyInfo { +export interface ProviderApiKeyInfo extends AccountQuotaFields { id: string; label?: string; /** First/last 4 chars only; env references (`${VAR}`) are shown verbatim (not secrets). */ @@ -57,17 +58,14 @@ function ensurePool(provider: OcxProviderConfig): NonNullable e.key === provider.apiKey) ?? pool[0]!).id; -} - export function listProviderApiKeys(config: OcxConfig, name: string): { activeId: string | null; keys: ProviderApiKeyInfo[] } { const provider = config.providers[name]; if (!provider || !isKeyAuthProvider(provider)) return { activeId: null, keys: [] }; - const pool = ensurePool(provider); - const activeId = activeEntryId(provider); + // A GET projects a legacy key without seeding/mutating live configuration. + 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; return { activeId, keys: pool.map(entry => ({ diff --git a/src/providers/quota-key-accounts.ts b/src/providers/quota-key-accounts.ts new file mode 100644 index 0000000000..7f406e0bed --- /dev/null +++ b/src/providers/quota-key-accounts.ts @@ -0,0 +1,141 @@ +/** Isolated, process-local API-key quota rows. Never publishes provider/routing caches. */ +import { createHash } from "node:crypto"; +import type { OcxConfig, OcxProviderConfig } from "../types"; +import { apiKeyPoolEntryId } from "./api-keys"; +import { resolveProviderApiKey } from "./key-store"; +import type { ProviderQuota } from "./quota-types"; +import { ACCOUNT_QUOTA_TTL_MS } from "./quota-wire"; + +const MAX_ENTRIES = 256; +const LAST_GOOD_MS = 30 * 60_000; +type Entry = { ts: number; quota: ProviderQuota | null; unavailable?: true }; +export type KeyQuotaProbeOutcome = + | { kind: "quota"; quota: ProviderQuota } + | { kind: "empty" | "terminal" | "unavailable" }; +export interface ProviderApiKeyQuota { + keyId: string; + quota: ProviderQuota | null; + unavailable?: true; + /** Server-only, non-enumerable; call immediately before the safe DTO join. */ + isCurrent: () => boolean; +} +const cache = new Map(); +const flights = new Map>(); +let epoch = 0; + +export function clearProviderApiKeyQuotaCache(): void { + epoch += 1; + cache.clear(); + flights.clear(); +} + +/** Four workers per roster, not a process-wide network limit. */ +export async function mapQuotaRoster(rows: readonly T[], read: (row: T) => Promise): Promise { + const out = new Array(rows.length); + let cursor = 0; + await Promise.all(Array.from({ length: Math.min(4, rows.length) }, async () => { + while (cursor < rows.length) { + const index = cursor++; + out[index] = await read(rows[index]!); + } + })); + return out; +} + +function roster(provider: OcxProviderConfig) { + return provider.apiKeyPool?.length ? provider.apiKeyPool + : provider.apiKey ? [{ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }] : []; +} + +function identity(name: string, provider: OcxProviderConfig, id: string, key: string): string { + return createHash("sha256").update(JSON.stringify([ + "quota-key", name, provider.adapter, provider.baseUrl, provider.authMode ?? "key", + provider.disabled === true, id, key, + ])).digest("hex"); +} + +function remember(key: string, entry: Entry): void { + cache.delete(key); + for (const [id, row] of cache) if (Date.now() - row.ts >= LAST_GOOD_MS) cache.delete(id); + while (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value!); + cache.set(key, entry); +} + +async function readEntry( + key: string, + force: boolean, + isCurrent: () => boolean, + probe: () => Promise, +): Promise { + const previous = cache.get(key); + if (!force && previous && Date.now() - previous.ts < ACCOUNT_QUOTA_TTL_MS + && (!previous.quota || Date.now() - previous.quota.updatedAt < LAST_GOOD_MS)) { + cache.delete(key); + cache.set(key, previous); + return previous; + } + const running = flights.get(key); + if (running) return running; + const lastGood = previous?.quota && Date.now() - previous.quota.updatedAt < LAST_GOOD_MS + ? previous.quota : null; + if (flights.size >= MAX_ENTRIES) return { ts: Date.now(), quota: lastGood, unavailable: true }; + const flight = (async (): Promise => { + let result: KeyQuotaProbeOutcome; + try { result = await probe(); } catch { result = { kind: "unavailable" }; } + const entry: Entry = result.kind === "quota" + ? { ts: Date.now(), quota: result.quota } + : result.kind === "empty" + ? { ts: Date.now(), quota: null } + : { + ts: Date.now(), + quota: result.kind !== "terminal" && lastGood && Date.now() - lastGood.updatedAt < LAST_GOOD_MS ? lastGood : null, + unavailable: true, + }; + if (isCurrent()) remember(key, entry); + return entry; + })().finally(() => { if (flights.get(key) === flight) flights.delete(key); }); + flights.set(key, flight); + return flight; +} + +export async function readProviderApiKeyQuotas( + config: OcxConfig, + name: string, + force: boolean, + probe: (provider: OcxProviderConfig, config: OcxConfig) => Promise, +): Promise { + const liveProvider = config.providers[name]; + if (!liveProvider) return []; + const providerSnapshot = { ...liveProvider }; + const rows = roster(liveProvider).map(row => ({ ...row })); + const generation = epoch; + return mapQuotaRoster(rows, async row => { + let resolved: string | undefined; + try { resolved = resolveProviderApiKey(row.key)?.trim(); } catch { /* unavailable */ } + const key = resolved ? identity(name, providerSnapshot, row.id, resolved) : null; + const isCurrent = () => { + if (epoch !== generation || !key) return false; + const current = config.providers[name]; + if (!current) return false; + const entry = roster(current).find(candidate => candidate.id === row.id); + if (!entry) return false; + try { + const currentKey = resolveProviderApiKey(entry.key)?.trim(); + return !!currentKey && identity(name, current, row.id, currentKey) === key; + } catch { return false; } + }; + const entry = key && resolved && isCurrent() + ? await readEntry(key, force, isCurrent, () => { + const isolatedProvider = { ...providerSnapshot, apiKey: resolved, apiKeyPool: undefined }; + const isolatedConfig = { ...config, providers: { [name]: isolatedProvider } }; + return probe(isolatedProvider, isolatedConfig); + }) + : { ts: Date.now(), quota: null, unavailable: true as const }; + const result = { + keyId: row.id, + quota: isCurrent() ? entry.quota : null, + ...(!isCurrent() || entry.unavailable ? { unavailable: true as const } : {}), + }; + return Object.defineProperty(result, "isCurrent", { value: isCurrent }) as ProviderApiKeyQuota; + }); +} diff --git a/src/providers/quota-types.ts b/src/providers/quota-types.ts index d71b49ea4d..873eb30221 100644 --- a/src/providers/quota-types.ts +++ b/src/providers/quota-types.ts @@ -34,3 +34,12 @@ export interface ProviderQuota { creditsUsd?: ProviderQuotaCreditsUsd; updatedAt: number; } + +export type AccountQuotaMode = "probe" | "passive" | "unsupported"; + +/** Additive management-row fields; cheap lists emit only quotaMode. */ +export interface AccountQuotaFields { + quotaMode?: AccountQuotaMode; + quota?: ProviderQuota | null; + quotaUnavailable?: boolean; +} diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 49533336c0..bb35c7ead1 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -48,6 +48,7 @@ import { type CodexCapacityQuota, } from "./codex-capacity"; import type { + AccountQuotaMode, ProviderQuota, ProviderQuotaCreditsUsd, ProviderQuotaWindow, @@ -65,6 +66,7 @@ import { readPersistedAccountQuotas, schedulePersistAccountQuotas, } from "./account-quota-disk"; +import { clearProviderApiKeyQuotaCache, mapQuotaRoster, readProviderApiKeyQuotas, type ProviderApiKeyQuota } from "./quota-key-accounts"; export type { ProviderQuota, ProviderQuotaCreditsUsd, ProviderQuotaWindow } from "./quota-types"; @@ -100,6 +102,7 @@ const XAI_CREDITS_URL = `${XAI_BILLING_URL}?format=credits`; /** Keep a failed probe's previous row at most this long before dropping it. */ const LAST_GOOD_MAX_AGE_MS = CODEX_CAPACITY_MAX_QUOTA_AGE_MS; const nativeMainReportGenerations = new WeakMap(); +const accountReportCurrent = new WeakMap boolean>(); let providerQuotaBeforePublishForTests: (() => void | Promise) | null = null; /** Test-only seam for identity/config invalidation after probes but before publication. */ @@ -163,6 +166,7 @@ let invalidationEpoch = 0; export function clearProviderQuotaCache(): void { cache = null; clearCachedProviderQuotas(); + clearProviderApiKeyQuotaCache(); invalidationEpoch += 1; } @@ -1182,7 +1186,8 @@ function tagNativeMainReport( function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolean { const generation = nativeMainReportGenerations.get(value); - return generation === undefined || isMainAccountIdentityGenerationLive(generation); + return (generation === undefined || isMainAccountIdentityGenerationLive(generation)) + && (accountReportCurrent.get(value)?.() ?? true); } async function fetchChatGptForwardQuota( @@ -1303,6 +1308,7 @@ export function parseXaiCreditsResponse(value: unknown): { percent: number; rese async function fetchXaiWeeklyCredits(accessToken: string, userId: string): Promise { try { const response = await fetch(XAI_CREDITS_URL, { + redirect: "error", headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}`, @@ -1326,16 +1332,11 @@ async function fetchXaiWeeklyCredits(accessToken: string, userId: string): Promi } } -async function fetchXaiQuota(provider: string): Promise { - let accessToken: string; - try { - accessToken = await getValidAccessToken("xai"); - } catch { - return null; - } +async function fetchXaiQuota(provider: string, context: { accessToken: string; upstreamAccountId?: string }): Promise { + const { accessToken } = context; // Prefer the SuperGrok weekly credits window that actually gates prompting (#1283). - const userId = getCredential("xai")?.accountId?.trim() || xaiUserIdFromAccessToken(accessToken); + const userId = context.upstreamAccountId?.trim() || xaiUserIdFromAccessToken(accessToken); if (userId) { const weekly = await fetchXaiWeeklyCredits(accessToken, userId); if (weekly) return report(provider, "xai:grok-billing-credits", weekly); @@ -1344,6 +1345,7 @@ async function fetchXaiQuota(provider: string): Promise boolean; }; const accountQuotaCache = new Map(); +let explicitAccountEpoch = 0; /** * Seed the cache from the last run, once. @@ -1591,11 +1597,21 @@ export interface ProviderAccountQuota { quota: ProviderQuota | null; /** Set when the probe could not reach upstream (expired login, 429, network). */ unavailable?: true; + isCurrent?: () => boolean; } /** Providers whose per-account quota can be probed. Extend as other OAuth APIs are covered. */ export function supportsPerAccountQuota(provider: string): boolean { - return provider === "anthropic" || provider === "kiro" || provider === "google-antigravity"; + return provider === "anthropic" || provider === "kiro" || provider === "google-antigravity" + || explicitAccountReader(provider); +} + +function explicitAccountReader(provider: string): boolean { + return provider === "xai" || provider === "cursor" || provider === "kimi" || provider === "command-code"; +} + +export function providerOAuthAccountQuotaMode(provider: string): AccountQuotaMode { + return hasPassiveAccountQuota(provider) ? "passive" : supportsPerAccountQuota(provider) ? "probe" : "unsupported"; } function accountCacheKey(provider: string, accountId: string): string { @@ -1608,6 +1624,7 @@ function accountCacheKey(provider: string, accountId: string): string { */ export function getCachedProviderAccountQuota(provider: string, accountId: string): ProviderQuota | null { const entry = accountQuotaCache.get(accountCacheKey(provider, accountId)); + if (entry?.isCurrent && !entry.isCurrent()) return null; return entry?.quota ?? null; } @@ -1629,11 +1646,8 @@ export function setCachedProviderAccountQuotaForTests( * Providers whose per-account quota is OBSERVED in-band, never probed. * * Deliberately separate from `supportsPerAccountQuota` rather than folded into it. That - * predicate gates `fetchAccountQuota`, whose fallback branch sends any - * non-Kiro/non-Antigravity bearer to Anthropic's usage endpoint — so adding `meta-muse` - * there without a dedicated branch would ship a Meta credential to Anthropic. And even - * with a branch it would be the wrong predicate: it means "this provider can be probed", - * and Meta publishes no quota endpoint to probe. + * predicate gates explicit upstream readers. Meta publishes no quota endpoint, so it + * remains a cache-only observation even when every probe reader is account-scoped. */ export function hasPassiveAccountQuota(provider: string): boolean { return provider === "meta-muse"; @@ -1739,6 +1753,7 @@ export function resetProviderQuotaReconcileStateForTests(): void { /** Drop cached per-account rows (all, or just one provider's). */ export function clearAccountQuotaCache(provider?: string): void { + explicitAccountEpoch += 1; if (!provider) { accountQuotaCache.clear(); accountQuotaInflight.clear(); @@ -1783,11 +1798,115 @@ async function getTokenForAccountQuotaProbe(provider: string, accountId: string) return getValidAccessTokenForAccount(provider, accountId); } +function explicitQuotaConfig(provider: string, configured?: OcxProviderConfig): OcxProviderConfig | undefined { + if (configured) return configured; + const entry = getProviderRegistryEntry(provider); + return entry ? { adapter: entry.adapter, baseUrl: entry.baseUrl, authMode: "oauth" } : undefined; +} + +function explicitQuotaIdentity(provider: string, accountId: string, configured?: OcxProviderConfig): string | undefined { + const credential = getAccountCredential(provider, accountId); + const target = explicitQuotaConfig(provider, configured); + if (!credential || !target) return undefined; + return createHash("sha256").update(JSON.stringify([ + provider, accountId, credential.access, credential.refresh, credential.expires, + credential.accountId, credential.projectId, credential.source, + target.adapter, target.baseUrl, target.authMode, target.disabled === true, + ])).digest("hex"); +} + +function explicitQuotaDestination(provider: string, config: OcxProviderConfig): boolean { + if (config.disabled === true || config.authMode !== "oauth") return false; + if (provider === "kimi") return isCanonicalKimiCodeBaseUrl(config.baseUrl); + if (provider === "command-code") return isCanonicalCommandCodeBaseUrl(config.baseUrl); + // These readers use fixed canonical billing origins, never config.baseUrl. + return provider === "xai" || provider === "cursor"; +} + +async function readExplicitAccountQuota(provider: string, accountId: string, configured?: OcxProviderConfig): Promise<{ + result: ProviderQuotaProbeResult; + identity: string | undefined; + isCurrent: () => boolean; +} | null> { + const target = explicitQuotaConfig(provider, configured); + if (!target || !explicitQuotaDestination(provider, target)) return null; + const config = { ...target }; + const epoch = explicitAccountEpoch; + const accessToken = await getTokenForAccountQuotaProbe(provider, accountId); + const credential = getAccountCredential(provider, accountId); + if (!credential || credential.access !== accessToken) return null; + // Pair the post-renewal credential with the destination captured before renewal. + const identity = explicitQuotaIdentity(provider, accountId, config); + const isCurrent = () => epoch === explicitAccountEpoch + && identity === explicitQuotaIdentity(provider, accountId, configured); + if (!isCurrent()) return null; + let result: ProviderQuotaProbeResult; + switch (provider) { + case "xai": result = await fetchXaiQuota(provider, { accessToken, upstreamAccountId: credential.accountId }); break; + case "cursor": result = await fetchCursorQuota(provider, accessToken); break; + case "kimi": result = await fetchKimiQuota(provider, config, accessToken); break; + case "command-code": result = await fetchCommandCodeQuota(provider, config, accessToken); break; + default: return null; + } + return { result, identity, isCurrent }; +} + +async function fetchExplicitAccountQuota(provider: string, accountId: string, force: boolean, configured?: OcxProviderConfig): Promise { + const key = accountCacheKey(provider, accountId); + const identity = explicitQuotaIdentity(provider, accountId, configured); + const previous = accountQuotaCache.get(key); + const cached = identity && previous?.identity === identity && previous.isCurrent?.() ? previous : undefined; + if (!force && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS + && (!cached.quota || Date.now() - cached.quota.updatedAt < LAST_GOOD_MAX_AGE_MS)) return cached; + const flightKey = `${key}\u0000${identity ?? "missing"}`; + const running = accountQuotaInflight.get(flightKey); + if (running) return running; + const epoch = explicitAccountEpoch; + const lastGood = cached?.quota && Date.now() - cached.quota.updatedAt < LAST_GOOD_MAX_AGE_MS ? cached.quota : null; + const flight = (async (): Promise => { + let read: Awaited> = null; + try { read = await readExplicitAccountQuota(provider, accountId, configured); } catch { /* unavailable */ } + const isCurrent = read?.isCurrent ?? (() => epoch === explicitAccountEpoch && !!identity + && identity === explicitQuotaIdentity(provider, accountId, configured)); + const result = read?.result; + const current = epoch === explicitAccountEpoch && isCurrent(); + const quota = current && result && typeof result !== "symbol" ? result.quota : null; + const empty = result === AUTHORITATIVE_EMPTY_QUOTA; + const entry: AccountQuotaCacheEntry = { + ts: Date.now(), + quota: quota ?? (current && result !== TERMINAL_QUOTA_FAILURE && !empty + && lastGood && Date.now() - lastGood.updatedAt < LAST_GOOD_MAX_AGE_MS ? lastGood : null), + ...(!current || (!quota && !empty) ? { unavailable: true as const } : {}), + identity: read?.identity ?? identity, + isCurrent: () => epoch === explicitAccountEpoch && isCurrent(), + }; + if (entry.isCurrent?.()) accountQuotaCache.set(key, entry); + return entry; + })().finally(() => { if (accountQuotaInflight.get(flightKey) === flight) accountQuotaInflight.delete(flightKey); }); + accountQuotaInflight.set(flightKey, flight); + return flight; +} + +async function fetchExplicitCurrentQuota(provider: string, config: OcxProviderConfig, liveConfig: OcxConfig): Promise { + const id = getAccountSet(provider)?.activeAccountId; + if (!id) return null; + const read = await readExplicitAccountQuota(provider, id, config); + if (!read) return null; + const isCurrent = () => liveConfig.providers[provider] === config + && read.isCurrent() && getAccountSet(provider)?.activeAccountId === id; + if (!isCurrent()) return TERMINAL_QUOTA_FAILURE; + if (read.result && typeof read.result !== "symbol") accountReportCurrent.set(read.result, isCurrent); + return read.result; +} + async function fetchAccountQuota( provider: string, accountId: string, forceRefresh: boolean, + providerConfig?: OcxProviderConfig, ): Promise { + if (!supportsPerAccountQuota(provider)) return { ts: Date.now(), quota: null, unavailable: true }; + if (explicitAccountReader(provider)) return fetchExplicitAccountQuota(provider, accountId, forceRefresh, providerConfig); const key = accountCacheKey(provider, accountId); const writerGeneration = captureConfigGeneration(); const cached = accountQuotaCache.get(key); @@ -1817,8 +1936,10 @@ async function fetchAccountQuota( const projectId = getAccountCredential(provider, accountId)?.projectId; if (!projectId) throw new Error("antigravity account has no project id"); quota = await fetchAntigravityUsageQuota(token, projectId); - } else { + } else if (provider === "anthropic") { quota = await fetchAnthropicUsageQuota(token); + } else { + return { ts: Date.now(), quota: null, unavailable: true }; } } if (!quota) { @@ -1871,18 +1992,27 @@ async function fetchAccountQuota( export async function fetchProviderAccountQuotas( provider: string, forceRefresh = false, + providerConfig?: OcxProviderConfig, ): Promise { if (!supportsPerAccountQuota(provider)) return []; const set = getAccountSet(provider); if (!set) return []; - return await Promise.all(set.accounts.map(async account => { - const entry = await fetchAccountQuota(provider, account.id, forceRefresh); - return { + return mapQuotaRoster(set.accounts, async account => { + const entry = await fetchAccountQuota(provider, account.id, forceRefresh, providerConfig); + const result: ProviderAccountQuota = { accountId: account.id, quota: entry.quota, ...(entry.unavailable ? { unavailable: true as const } : {}), }; - })); + if (!explicitAccountReader(provider)) return result; + const identity = entry.identity; + Object.defineProperty(result, "isCurrent", { value: () => { + if (entry.isCurrent) return entry.isCurrent(); + const credential = getAccountCredential(provider, account.id); + return !!credential && (!identity || explicitQuotaIdentity(provider, account.id, providerConfig) === identity); + } }); + return result; + }); } function normalizedBaseUrl(value: string): string | null { @@ -2001,10 +2131,10 @@ function parseKimiQuotaPayload(value: unknown): ProviderQuota | null { return hasQuotaRows(quota) ? quota : null; } -async function resolveKimiQuotaBearer(config: OcxProviderConfig): Promise { +async function resolveKimiQuotaBearer(config: OcxProviderConfig, accountId?: string): Promise { if (config.authMode === "oauth") { try { - return await getValidAccessToken("kimi"); + return accountId ? await getTokenForAccountQuotaProbe("kimi", accountId) : null; } catch { return null; } @@ -2016,13 +2146,13 @@ async function resolveKimiQuotaBearer(config: OcxProviderConfig): Promise { +async function fetchKimiQuota(provider: string, config: OcxProviderConfig, accessToken: string): Promise { // Never release credentials to a user-edited or lookalike provider host. if (!isCanonicalKimiCodeBaseUrl(config.baseUrl)) return null; - const accessToken = await resolveKimiQuotaBearer(config); if (!accessToken) return null; const response = await fetch(KIMI_CODE_USAGE_URL, { headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` }, + redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) return null; @@ -2109,10 +2239,10 @@ async function fetchCommandCodeSpend( } /** OAuth access token or ACTIVE Provider-API key for the Command Code quota probe. */ -async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig): Promise { +async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig, accountId?: string): Promise { if (config.authMode === "oauth") { try { - return await getValidAccessToken("command-code"); + return accountId ? await getTokenForAccountQuotaProbe("command-code", accountId) : null; } catch { return null; } @@ -2127,10 +2257,9 @@ async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig): Promise * usage view uses (windowLimits.fiveHour / windowLimits.weekly), plus soft * whoami (team orgId scoping) and subscription-scoped spend for creditsUsd. */ -async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig): Promise { +async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig, bearer: string): Promise { // Never release credentials to a user-edited or lookalike provider host. if (!isCanonicalCommandCodeBaseUrl(config.baseUrl)) return null; - const bearer = await resolveCommandCodeQuotaBearer(config); if (!bearer) return null; const whoamiBody = await fetchCommandCodeJson(COMMAND_CODE_WHOAMI_URL, bearer); const whoami = asRecord(whoamiBody?.data) ?? whoamiBody; @@ -2170,13 +2299,7 @@ async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig } /** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */ -async function fetchCursorQuota(provider: string): Promise { - let accessToken: string; - try { - accessToken = await getValidAccessToken("cursor"); - } catch { - return null; - } +async function fetchCursorQuota(provider: string, accessToken: string): Promise { const authHeaders = { Accept: "application/json", @@ -2189,6 +2312,7 @@ async function fetchCursorQuota(provider: string): Promise Promise; + +/** Same selector drives cheap capabilities and uncached reads; never resolves credentials. */ +function keyQuotaReaderForProvider(name: string, provider: OcxProviderConfig): KeyQuotaReader | null { + if (provider.disabled === true || (provider.authMode ?? "key") !== "key") return null; + if (isCanonicalKimiCodeBaseUrl(provider.baseUrl)) { + return async (id, config) => { + const bearer = await resolveKimiQuotaBearer(config); + return bearer ? fetchKimiQuota(id, config, bearer) : null; + }; + } + if (name === "commandcode" && isCanonicalCommandCodeBaseUrl(provider.baseUrl)) { + return async (id, config) => { + const bearer = await resolveCommandCodeQuotaBearer(config); + return bearer ? fetchCommandCodeQuota(id, config, bearer) : null; + }; + } + if (registryEntryForProviderDestination(provider)?.id === "opencode-go") return fetchOpenCodeGoQuota; + if (isCanonicalA6apiBaseUrl(provider.baseUrl)) return fetchA6apiQuota; + if (name === "openrouter" && isCanonicalOpenRouterBaseUrl(provider.baseUrl)) return fetchOpenRouterQuota; + if (name === "deepseek" && isCanonicalDeepSeekBaseUrl(provider.baseUrl)) return fetchDeepSeekQuota; + if (name === "cline-pass" && isCanonicalClineBaseUrl(provider.baseUrl)) return fetchClineQuota; + if (isCanonicalOllamaCloudBaseUrl(provider.baseUrl ?? getProviderRegistryEntry(name)?.baseUrl)) return fetchOllamaCloudQuota; + if (["zai", "glm", "glm-cn", "zhipu-bigmodel-coding"].includes(name) && isCanonicalZaiBaseUrl(provider.baseUrl)) return fetchZaiQuota; + if (["minimax", "minimax-cn"].includes(name) && isCanonicalMinimaxBaseUrl(provider.baseUrl)) return fetchMinimaxQuota; + if (name === "moonshot" && isCanonicalMoonshotBaseUrl(provider.baseUrl)) return fetchMoonshotQuota; + if (name === "venice" && isCanonicalVeniceBaseUrl(provider.baseUrl)) return fetchVeniceQuota; + if (name === "synthetic" && isCanonicalSyntheticBaseUrl(provider.baseUrl)) return fetchSyntheticQuota; + if (name === "deepinfra" && isCanonicalDeepInfraBaseUrl(provider.baseUrl)) return fetchDeepInfraQuota; + if (name === "neuralwatt" && isCanonicalNeuralwattBaseUrl(provider.baseUrl)) return fetchNeuralwattQuota; + return null; +} + +export function providerApiKeyQuotaMode(name: string, provider: OcxProviderConfig): AccountQuotaMode { + return keyQuotaReaderForProvider(name, provider) ? "probe" : "unsupported"; +} + +export async function fetchProviderApiKeyQuotas(config: OcxConfig, name: string, forceRefresh = false): Promise { + const provider = config.providers[name]; + if (!provider || !keyQuotaReaderForProvider(name, provider)) return []; + return readProviderApiKeyQuotas(config, name, forceRefresh, async (isolatedProvider, isolatedConfig) => { + const result = await maybeFetchProviderQuota(name, isolatedProvider, isolatedConfig, false); + if (result === TERMINAL_QUOTA_FAILURE) return { kind: "terminal" }; + if (result === AUTHORITATIVE_EMPTY_QUOTA) return { kind: "empty" }; + return result ? { kind: "quota", quota: result.quota } : { kind: "unavailable" }; + }); +} + async function maybeFetchProviderQuota( name: string, provider: OcxProviderConfig, @@ -2607,78 +2781,15 @@ async function maybeFetchProviderQuota( if (isBuiltInChatGptForwardProvider(name, provider)) { return fetchChatGptForwardQuota(config, name, provider, forceRefresh, prefetchedCodexSnapshot); } - if (provider.authMode === "oauth" && name === "xai") return fetchXaiQuota(name); + if (provider.authMode === "oauth" && explicitAccountReader(name)) return await fetchExplicitCurrentQuota(name, provider, config); if (provider.authMode === "oauth" && name === "anthropic") return fetchAnthropicQuota(name); - if (provider.authMode === "oauth" && name === "cursor") return fetchCursorQuota(name); if (provider.authMode === "oauth" && name === "google-antigravity") return fetchAntigravityQuota(name, provider); if (provider.authMode === "oauth" && name === "kiro") return fetchKiroQuota(name); // Passive providers (meta-muse): Meta publishes no quota endpoint, so there is no // probe to run — the row is the active account's last in-band observation. if (provider.authMode === "oauth" && hasPassiveAccountQuota(name)) return fetchPassiveProviderQuota(name); - // Kimi Code `/usages` accepts OAuth or coding-plan API keys, but only on the canonical - // host and only for real key auth — forward/local modes carry no credential of ours. - if (provider.authMode === "oauth" && name === "kimi") return fetchKimiQuota(name, provider); - if (provider.authMode === "key" && isCanonicalKimiCodeBaseUrl(provider.baseUrl)) { - return fetchKimiQuota(name, provider); - } - // OAuth account login or Provider-API key only; forward/local modes carry no - // credential of ours on the canonical host. - if (provider.authMode === "oauth" && name === "command-code") { - return fetchCommandCodeQuota(name, provider); - } - if ((provider.authMode ?? "key") === "key" && name === "commandcode" - && isCanonicalCommandCodeBaseUrl(provider.baseUrl)) { - return fetchCommandCodeQuota(name, provider); - } - // Identify OpenCode Go by where it routes, not by what the row is called. Multi-account - // setups keep the same destination under names like `opencode-go-2` (#1924), and those rows - // silently had no quota panel and no `ocx provider quota --json` report while the literal - // name was the gate. `registryEntryForProviderDestination` is the existing predicate for - // exactly this question: normalized endpoint + adapter + key auth, so a canonical URL behind - // a different adapter is still not OpenCode Go. The defensive URL check inside - // `fetchOpenCodeGoQuota` stays — sending a key anywhere must not depend on this gate. - if ((provider.authMode ?? "key") === "key" && registryEntryForProviderDestination(provider)?.id === "opencode-go") { - return fetchOpenCodeGoQuota(name, provider); - } - if ((provider.authMode ?? "key") === "key" && isCanonicalA6apiBaseUrl(provider.baseUrl)) { - return fetchA6apiQuota(name, provider); - } - if ((provider.authMode ?? "key") === "key" && name === "openrouter") { - return fetchOpenRouterQuota(name, provider); - } - if ((provider.authMode ?? "key") === "key" && name === "deepseek") { - return fetchDeepSeekQuota(name, provider); - } - if ((provider.authMode ?? "key") === "key" && name === "cline-pass") { - return fetchClineQuota(name, provider); - } - if ((provider.authMode ?? "key") === "key" - && (name === "ollama-cloud" || isCanonicalOllamaCloudBaseUrl(provider.baseUrl))) { - return fetchOllamaCloudQuota(name, provider); - } - if ((provider.authMode ?? "key") === "key" - && (name === "zai" || name === "glm" || name === "glm-cn" || name === "zhipu-bigmodel-coding")) { - return fetchZaiQuota(name, provider); - } - if ((provider.authMode ?? "key") === "key" && (name === "minimax" || name === "minimax-cn")) { - return fetchMinimaxQuota(name, provider); - } - if ((provider.authMode ?? "key") === "key" && name === "moonshot") { - return fetchMoonshotQuota(name, provider); - } - if ((provider.authMode ?? "key") === "key" && name === "venice") { - return fetchVeniceQuota(name, provider); - } - if ((provider.authMode ?? "key") === "key" && name === "synthetic") { - return fetchSyntheticQuota(name, provider); - } - if ((provider.authMode ?? "key") === "key" && name === "deepinfra") { - return fetchDeepInfraQuota(name, provider); - } - if ((provider.authMode ?? "key") === "key" && name === "neuralwatt") { - return fetchNeuralwattQuota(name, provider); - } - return null; + const reader = keyQuotaReaderForProvider(name, provider); + return reader ? reader(name, provider) : null; } catch { return null; } diff --git a/src/router.ts b/src/router.ts index 4af0ea497e..758f34e751 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; @@ -601,11 +608,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..afafe4f56e 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,11 @@ 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"); + } 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..595928c0c3 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,11 @@ async function handleClaudeMessagesWithBudget( if (ladder !== undefined && ladder.length === 0) delete internalBody.reasoning; } } 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"); + } 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/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 763cbc0257..07884d5f4f 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -30,7 +30,7 @@ import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/ke import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; -import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderQuotaReports, hasPassiveAccountQuota, readPassiveProviderAccountQuotas, supportsPerAccountQuota } from "../../providers/quota"; +import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderApiKeyQuotas, fetchProviderQuotaReports, providerOAuthAccountQuotaMode, providerApiKeyQuotaMode, readPassiveProviderAccountQuotas } from "../../providers/quota"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { clearThreadAccountMap } from "../../codex/routing"; import { @@ -254,7 +254,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< if (url.pathname === "/api/oauth/accounts" && req.method === "GET") { const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); - const status = getLoginStatus(provider); + const quotaMode = providerOAuthAccountQuotaMode(provider); + const quotaProvider = config.providers[provider]; const { getAccountSet } = await import("../../oauth/store"); const { oauthAccountHealthFields, @@ -274,26 +275,26 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< needsReauth: summary.needsReauth === true, reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined, }); - return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health) }; + return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode }; }), }; }; // Per-account rate limits: Anthropic reports usage per credential, so every logged-in // account can show its own 5h/weekly bars (not just the active one). Opt-in via ?quota=1 // so the plain account list stays a cheap local read; ?refresh=1 bypasses the TTL. - const wantQuota = url.searchParams.get("quota") === "1" && supportsPerAccountQuota(provider); + const wantQuota = url.searchParams.get("quota") === "1" && quotaMode === "probe"; // Meta publishes no quota endpoint: its usage is observed in-band on streaming turns // and read back from the cache here. `?refresh=1` is accepted and ignored on this // path rather than rejected -- the GUI sends it for every provider on a manual // refresh, and a 400 would report an error for what is simply a no-op. - const passiveQuota = url.searchParams.get("quota") === "1" && hasPassiveAccountQuota(provider); + const passiveQuota = url.searchParams.get("quota") === "1" && quotaMode === "passive"; if (!wantQuota && !passiveQuota) return jsonResponse(projectAccounts()); const forceRefresh = url.searchParams.get("refresh") === "1"; // Probing may refresh the active credential and mark needsReauth — project health // from the post-probe store so the response is not stale. const rows = passiveQuota ? readPassiveProviderAccountQuotas(provider) - : await fetchProviderAccountQuotas(provider, forceRefresh); + : await fetchProviderAccountQuotas(provider, forceRefresh, quotaProvider); const byId = new Map(rows.map(row => [row.accountId, row])); const projected = projectAccounts(); return jsonResponse({ @@ -301,10 +302,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< accounts: projected.accounts.map(account => { const row = byId.get(account.id); if (!row) return account; + if (config.providers[provider] !== quotaProvider || row.isCurrent?.() === false) { + return { ...account, quota: null, quotaUnavailable: true }; + } return { ...account, quota: row.quota, - ...(row.unavailable ? { quotaUnavailable: true } : {}), + ...(quotaMode === "probe" ? { quotaUnavailable: row.unavailable === true } : {}), }; }), }); @@ -572,7 +576,29 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const name = (url.searchParams.get("name") ?? "").trim(); if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404); const { listProviderApiKeys } = await import("../../providers/api-keys"); - return jsonResponse(listProviderApiKeys(config, name)); + const projectKeys = () => { + const listed = listProviderApiKeys(config, name); + const provider = config.providers[name]; + const quotaMode = provider ? providerApiKeyQuotaMode(name, provider) : "unsupported"; + return { ...listed, keys: listed.keys.map(key => ({ ...key, quotaMode })) }; + }; + const initial = projectKeys(); + if (url.searchParams.get("quota") !== "1" || !initial.keys.some(key => key.quotaMode === "probe")) { + return jsonResponse(initial); + } + const rows = await fetchProviderApiKeyQuotas(config, name, url.searchParams.get("refresh") === "1"); + const byId = new Map(rows.map(row => [row.keyId, row])); + const current = projectKeys(); + return jsonResponse({ + activeId: current.activeId, + keys: current.keys.map(key => { + const row = byId.get(key.id); + if (!row || key.quotaMode !== "probe") return key; + if (!row.isCurrent()) return { ...key, quota: null, quotaUnavailable: true }; + // Internal identity/epoch checks never enter the JSON DTO. + return { ...key, quota: row.quota, quotaUnavailable: row.unavailable === true }; + }), + }); } if (url.pathname === "/api/providers/keys" && req.method === "POST") { const body = await readManagementJsonBodyOr(req, {}) as { name?: string; key?: string; label?: string }; diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index 04cffdc146..6591bddd1b 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -55,6 +55,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"; @@ -95,7 +96,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[]; }; @@ -134,8 +135,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, @@ -164,7 +165,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/01_runtime.md b/structure/01_runtime.md index da4f7226aa..26f501419a 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -99,6 +99,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/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 678ee07645..f58b717660 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -341,6 +341,18 @@ keeps the saved state and renders fixed `ocx sync` guidance without server/accou ## Usage accounting +Account quota discovery is capability-based. Cheap OAuth and provider-key lists include +`quotaMode` (`probe`, `passive`, or `unsupported`) without contacting upstream quota APIs. +`GET /api/oauth/accounts?provider=..."a=1` and +`GET /api/providers/keys?name=..."a=1` enrich each supported credential separately; +`refresh=1` bypasses settled quota cache while joining a current same-identity read. +OAuth readers use the named stored account; key readers use isolated per-key configuration, +never active-key mutation or the provider-wide cache. Response projection rechecks key identity +and exposes only quota/availability fields, not its internal identity guard. Passive observations +retain their original timestamp and never trigger inference or token renewal. Unsupported, +unobserved, failed and measured-zero readings remain distinct; multiple keys are not summed +because they may share one upstream balance. + `src/usage/log.ts` writes append-only JSONL to `~/.opencodex/usage.jsonl` with file mode `0o600`. An opt-in shadow-call rewrite persists the bounded, redacted original helper model as `shadowCallRewrittenFrom`, so helper traffic remains identifiable after restart without storing @@ -364,6 +376,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/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/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 5a0ade2c3a..18e85cd235 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -762,6 +762,7 @@ "process-state.test.ts": "service", "project-config-warnings.test.ts": "codex-integration", "provider-account-quota-persistence.test.ts": "providers", + "provider-account-quota-routes.test.ts": "server", "provider-account-quota.test.ts": "providers", "provider-api-keys.test.ts": "providers", "provider-capacity.test.ts": "providers", diff --git a/tests/gui/rate-limit-reset-credits.test.ts b/tests/gui/rate-limit-reset-credits.test.ts index d88f532464..6078ea84a9 100644 --- a/tests/gui/rate-limit-reset-credits.test.ts +++ b/tests/gui/rate-limit-reset-credits.test.ts @@ -499,6 +499,7 @@ 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); const stored = getAccountQuota("burst-A"); expect(stored).toEqual({ @@ -511,6 +512,8 @@ describe("rate-limit reset credits", () => { updatedAt: expect.any(Number), }); expect(stored?.shortObservedAt).toBe(stored?.updatedAt); + expect(stored?.shortObservedAt).toBeGreaterThanOrEqual(observedAfter); + expect(stored?.shortObservedAt).toBeLessThanOrEqual(Date.now()); }); it("an exhausted burst window does not poison the weekly reading", () => { 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), + }); + }); }); diff --git a/tests/providers/provider-account-quota.test.ts b/tests/providers/provider-account-quota.test.ts index 0f0b242df6..1855e92d66 100644 --- a/tests/providers/provider-account-quota.test.ts +++ b/tests/providers/provider-account-quota.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 { saveCredential } from "../../src/oauth/store"; +import { getAccountSet, saveCredential, setActiveAccount } from "../../src/oauth/store"; import type { OcxConfig } from "../../src/types"; import { clearAccountQuotaCache, @@ -13,6 +13,7 @@ import { reconcileProviderAccountQuotaRows, resetProviderQuotaReconcileStateForTests, supportsPerAccountQuota, + providerOAuthAccountQuotaMode, } from "../../src/providers/quota"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -204,13 +205,12 @@ describe("fetchProviderAccountQuotas", () => { test("providers without a per-account usage API are skipped", async () => { expect(supportsPerAccountQuota("anthropic")).toBe(true); - // Kiro joined this list once it grew a usage reader; xAI has no per-account usage API, - // so it now carries the "unsupported providers never reach the network" contract. + // Login capability alone must never select another provider's quota reader. expect(supportsPerAccountQuota("kiro")).toBe(true); - expect(supportsPerAccountQuota("xai")).toBe(false); + expect(supportsPerAccountQuota("github-copilot")).toBe(false); let called = false; globalThis.fetch = (async () => { called = true; return new Response("{}", { status: 200 }); }) as typeof fetch; - expect(await fetchProviderAccountQuotas("xai")).toEqual([]); + expect(await fetchProviderAccountQuotas("github-copilot")).toEqual([]); expect(called).toBe(false); }); @@ -424,6 +424,228 @@ describe("fetchProviderAccountQuotas", () => { }); }); +describe("explicit OAuth account quota readers", () => { + test("explicit OAuth probe failure drops last-good quota that expires during the upstream await", async () => { + const realDateNow = Date.now; + const observedAt = realDateNow(); + await saveCredential("kimi", { + access: "quota-settlement-fixture", refresh: "refresh-settlement-fixture", + expires: observedAt + 60 * 60_000, accountId: "quota-settlement-user", + }); + let now = observedAt; + let calls = 0; + globalThis.fetch = (async () => { + calls++; + if (calls === 1) return Response.json({ usage: { limit: 100, used: 25 } }); + expect(now).toBe(observedAt + 29 * 60_000 + 59_000); + now = observedAt + 30 * 60_000; + return new Response("{}", { status: 429 }); + }) as typeof fetch; + Date.now = () => now; + try { + expect((await fetchProviderAccountQuotas("kimi"))[0]?.quota?.updatedAt).toBe(observedAt); + now = observedAt + 29 * 60_000 + 59_000; + const [failed] = await fetchProviderAccountQuotas("kimi", true); + expect(calls).toBe(2); + expect(failed?.quota).toBeNull(); + expect(failed?.unavailable).toBe(true); + } finally { + Date.now = realDateNow; + } + }); + + test("a recent failed explicit OAuth probe cannot extend a last-good measurement past thirty minutes", async () => { + const realDateNow = Date.now; + const observedAt = realDateNow(); + await saveCredential("kimi", { + access: "quota-age-fixture", refresh: "refresh-age-fixture", + expires: observedAt + 60 * 60_000, accountId: "quota-age-user", + }); + let now = observedAt; + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return calls === 1 ? Response.json({ usage: { limit: 100, used: 25 } }) : new Response("{}", { status: 429 }); + }) as typeof fetch; + Date.now = () => now; + try { + const [initial] = await fetchProviderAccountQuotas("kimi"); + expect(initial?.quota?.updatedAt).toBe(observedAt); + now = observedAt + 29 * 60_000 + 59_000; + const [failed] = await fetchProviderAccountQuotas("kimi", true); + expect(failed?.unavailable).toBe(true); + expect(failed?.quota?.updatedAt).toBe(observedAt); + expect(calls).toBe(2); + now += 1_000; + const [expired] = await fetchProviderAccountQuotas("kimi"); + expect(expired?.quota).toBeNull(); + expect(expired?.unavailable).toBe(true); + expect(calls).toBe(3); + now += 1; + expect((await fetchProviderAccountQuotas("kimi"))[0]?.quota).toBeNull(); + expect(calls).toBe(3); + } finally { + Date.now = realDateNow; + } + }); + + const cases = [ + { provider: "xai", adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", field: "weeklyPercent" }, + { provider: "cursor", adapter: "cursor", baseUrl: "https://api2.cursor.sh", field: "monthlyPercent" }, + { provider: "kimi", adapter: "openai-chat", baseUrl: "https://api.kimi.com/coding/v1", field: "weeklyPercent" }, + { provider: "command-code", adapter: "command-code", baseUrl: "https://api.commandcode.ai", field: "fiveHourPercent" }, + ] as const; + + test("capability distinguishes seven readers, passive observations and unsupported login providers", () => { + for (const provider of ["anthropic", "kiro", "google-antigravity", ...cases.map(row => row.provider)]) { + expect(providerOAuthAccountQuotaMode(provider)).toBe("probe"); + } + expect(providerOAuthAccountQuotaMode("meta-muse")).toBe("passive"); + expect(supportsPerAccountQuota("meta-muse")).toBe(false); + expect(providerOAuthAccountQuotaMode("github-copilot")).toBe("unsupported"); + }); + + for (const fixture of cases) { + test(`${fixture.provider} reads both credentials without switching active selection`, async () => { + const expires = Date.now() + 60 * 60_000; + for (const name of ["first", "second"]) { + await saveCredential(fixture.provider, { access: `quota-${name}`, refresh: `refresh-${name}`, expires, accountId: `user-${name}`, email: `${name}@example.com` }); + } + const active = getAccountSet(fixture.provider)!.activeAccountId; + const seen = new Set(); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + const auth = headers.get("authorization")!; + const first = auth === "Bearer quota-first"; + const amount = first ? 12 : 68; + const label = first ? "first" : "second"; + seen.add(auth); + const url = String(input); + expect(init?.redirect).toBe("error"); + expect(new URL(url).protocol).toBe("https:"); + if (fixture.provider === "xai") { + expect(headers.get("x-userid")).toBe(`user-${label}`); + return Response.json({ config: { creditUsagePercent: amount, currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY" } } }); + } + if (fixture.provider === "cursor") return Response.json({ planUsage: { totalPercentUsed: amount } }); + if (fixture.provider === "kimi") return Response.json({ usage: { limit: 100, used: amount } }); + if (url.endsWith("/alpha/whoami")) return Response.json({ org: { id: `org-${label}` } }); + expect(new URL(url).searchParams.get("orgId")).toBe(`org-${label}`); + if (url.includes("subscriptions")) return Response.json({ currentPeriodStart: "2026-09-01T00:00:00Z" }); + if (url.includes("usage/summary")) return Response.json({ totalCost: amount }); + return Response.json({ credits: { monthlyCredits: 10 }, windowLimits: { fiveHour: { cap: 100, used: amount } } }); + }) as typeof fetch; + const rows = await fetchProviderAccountQuotas(fixture.provider, false, { adapter: fixture.adapter, baseUrl: fixture.baseUrl, authMode: "oauth" }); + expect(rows.map(row => row.quota?.[fixture.field]).sort((a, b) => (a ?? 0) - (b ?? 0))).toEqual([12, 68]); + expect(seen.size).toBe(2); + expect(getAccountSet(fixture.provider)!.activeAccountId).toBe(active); + expect(rows.every(row => row.isCurrent?.())).toBe(true); + expect(Object.keys(rows[0]!)).toEqual(["accountId", "quota"]); + expect(JSON.stringify(rows)).not.toContain("quota-first"); + expect(JSON.stringify(rows)).not.toContain("identity"); + clearAccountQuotaCache(fixture.provider); + expect(rows.every(row => row.isCurrent?.() === false)).toBe(true); + }); + } + + test("same-id credential replacement invalidates a pending row and cannot poison the cache", async () => { + const credential = { access: "cursor-before", refresh: "cursor-refresh", expires: Date.now() + 60 * 60_000, accountId: "same-user", email: "same@example.com" }; + await saveCredential("cursor", credential); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let started!: () => void; + const entered = new Promise(resolve => { started = resolve; }); + globalThis.fetch = (async () => { + started(); await gate; + return Response.json({ planUsage: { totalPercentUsed: 95 } }); + }) as typeof fetch; + const pending = fetchProviderAccountQuotas("cursor"); + await entered; + await saveCredential("cursor", { ...credential, access: "cursor-after" }); + release(); + const [row] = await pending; + expect(row?.isCurrent?.()).toBe(false); + expect(row?.quota).toBeNull(); + expect(getCachedProviderAccountQuota("cursor", row!.accountId)).toBeNull(); + }); + + test("current provider report rejects an account switch during its explicit read", async () => { + for (const accountId of ["first-user", "second-user"]) { + await saveCredential("cursor", { access: accountId, refresh: `${accountId}-refresh`, expires: Date.now() + 60 * 60_000, accountId }); + } + const set = getAccountSet("cursor")!; + const next = set.accounts.find(row => row.id !== set.activeAccountId)!; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let started!: () => void; + const entered = new Promise(resolve => { started = resolve; }); + globalThis.fetch = (async () => { started(); await gate; return Response.json({ planUsage: { totalPercentUsed: 90 } }); }) as typeof fetch; + const config = { defaultProvider: "cursor", providers: { cursor: { adapter: "cursor", authMode: "oauth", baseUrl: "https://api2.cursor.sh" } } } as OcxConfig; + const pending = fetchProviderQuotaReports(config, true); + await entered; + await setActiveAccount("cursor", next.id); + release(); + expect((await pending).reports).toEqual([]); + }); + + test("invalid configured Kimi destination never resolves to the default quota host", async () => { + await saveCredential("kimi", { access: "kimi-fixture", refresh: "refresh", expires: Date.now() + 60 * 60_000, accountId: "kimi-user" }); + let calls = 0; + globalThis.fetch = (async () => { calls++; return Response.json({}); }) as typeof fetch; + const rows = await fetchProviderAccountQuotas("kimi", true, { adapter: "openai-chat", authMode: "oauth", baseUrl: "https://kimi.example.invalid/v1" }); + expect(rows[0]?.unavailable).toBe(true); + expect(rows[0]?.quota).toBeNull(); + expect(calls).toBe(0); + }); + + test("OAuth roster uses four workers and force joins same-identity work", async () => { + for (let i = 0; i < 6; i++) { + await saveCredential("cursor", { access: `cursor-${i}`, refresh: `refresh-${i}`, expires: Date.now() + 60 * 60_000, accountId: `user-${i}` }); + } + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let fourStarted!: () => void; + const entered = new Promise(resolve => { fourStarted = resolve; }); + let calls = 0; + let active = 0; + let peak = 0; + globalThis.fetch = (async () => { + calls++; active++; peak = Math.max(peak, active); + if (calls === 4) fourStarted(); + await gate; + active--; + return Response.json({ planUsage: { totalPercentUsed: 20 } }); + }) as typeof fetch; + const pending = fetchProviderAccountQuotas("cursor"); + await entered; + expect(calls).toBe(4); + release(); + expect(await pending).toHaveLength(6); + expect(peak).toBe(4); + await fetchProviderAccountQuotas("cursor"); + expect(calls).toBe(6); + await fetchProviderAccountQuotas("cursor", true); + expect(calls).toBe(12); + }); + + test("forced OAuth quota waits for an already-running same-identity probe", async () => { + await saveCredential("cursor", { access: "cursor-only", refresh: "refresh", expires: Date.now() + 60 * 60_000, accountId: "only-user" }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let started!: () => void; + const entered = new Promise(resolve => { started = resolve; }); + let calls = 0; + globalThis.fetch = (async () => { calls++; started(); await gate; return Response.json({ planUsage: { totalPercentUsed: 20 } }); }) as typeof fetch; + const ordinary = fetchProviderAccountQuotas("cursor"); + await entered; + const force = fetchProviderAccountQuotas("cursor", true); + release(); + const [a, b] = await Promise.all([ordinary, force]); + expect(calls).toBe(1); + expect(a[0]?.quota).toEqual(b[0]?.quota); + }); +}); + describe("google-antigravity per-account quota (#1082)", () => { const { setAntigravityAccountQuotaTransportForTests } = require("../../src/providers/quota") as typeof import("../../src/providers/quota"); const { getAccountSet } = require("../../src/oauth/store") as typeof import("../../src/oauth/store"); diff --git a/tests/providers/provider-api-keys.test.ts b/tests/providers/provider-api-keys.test.ts index 88bb2393b7..919f9298b7 100644 --- a/tests/providers/provider-api-keys.test.ts +++ b/tests/providers/provider-api-keys.test.ts @@ -8,6 +8,10 @@ import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { listProviderApiKeys } from "../../src/providers/api-keys"; +import { clearProviderQuotaCache, fetchProviderApiKeyQuotas, fetchProviderQuotaReports, providerApiKeyQuotaMode } from "../../src/providers/quota"; + +const originalUpstreamFetch = globalThis.fetch; let testDir = ""; let previousHome: string | undefined; @@ -25,6 +29,7 @@ function baseConfig(): OcxConfig { } beforeEach(() => { + clearProviderQuotaCache(); previousHome = process.env.OPENCODEX_HOME; isolatedCodexHome = installIsolatedCodexHome("ocx-provider-keys-codex-"); testDir = mkdtempSync(join(tmpdir(), "ocx-provider-keys-")); @@ -33,6 +38,8 @@ beforeEach(() => { }); afterEach(() => { + globalThis.fetch = originalUpstreamFetch; + clearProviderQuotaCache(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; isolatedCodexHome?.restore(); @@ -41,6 +48,19 @@ afterEach(() => { }); describe("provider API key pool", () => { + test("cheap legacy projection never mutates even frozen config or writes config", () => { + const config = baseConfig(); + const disk = readFileSync(join(testDir, "config.json"), "utf8"); + Object.freeze(config.providers["opencode-go"]); + Object.freeze(config.providers); + Object.freeze(config); + const first = listProviderApiKeys(config, "opencode-go"); + expect(listProviderApiKeys(config, "opencode-go")).toEqual(first); + expect(first.keys).toHaveLength(1); + expect(first.keys[0]?.active).toBe(true); + expect(config.providers["opencode-go"]?.apiKeyPool).toBeUndefined(); + expect(readFileSync(join(testDir, "config.json"), "utf8")).toBe(disk); + }); test("GET seeds legacy bare apiKey into a one-entry pool with masked value", async () => { const server = startServer(0); try { @@ -118,3 +138,289 @@ describe("provider API key pool", () => { } }); }); + +function quotaKeyConfig(count = 2): OcxConfig { + return { + defaultProvider: "openrouter", + providers: { openrouter: { + adapter: "openai-chat", authMode: "key", baseUrl: "https://openrouter.ai/api/v1", + apiKey: "fixture-key-0", apiKeyPool: Array.from({ length: count }, (_, i) => ({ id: `slot-${i}`, key: `fixture-key-${i}` })), + } }, + } as OcxConfig; +} + +function keyQuotaResponse(percent: number): Response { + return Response.json({ data: { limit: 100, limit_remaining: 100 - percent } }); +} + +describe("credential-scoped key quota", () => { + test("the integrated Ollama Cloud reader supports each key without changing active configuration", async () => { + const provider = { adapter: "openai-chat", authMode: "key" as const, baseUrl: "https://ollama.com", apiKey: "fixture-first", + apiKeyPool: [{ id: "first", key: "fixture-first" }, { id: "second", key: "fixture-second" }] }; + const config: OcxConfig = { port: 0, defaultProvider: "cloud-copy", providers: { "cloud-copy": provider } }; + const before = JSON.stringify(config); + expect(providerApiKeyQuotaMode("cloud-copy", provider)).toBe("probe"); + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe("https://ollama.com/api/usage"); + expect(init?.redirect).toBe("error"); + const first = new Headers(init?.headers).get("authorization") === "Bearer fixture-first"; + return Response.json({ limits: { monthly: { usage: first ? 0.25 : 0.75 } } }); + }) as typeof globalThis.fetch; + const rows = await fetchProviderApiKeyQuotas(config, "cloud-copy"); + expect(rows.map(row => row.quota?.monthlyPercent)).toEqual([25, 75]); + expect(JSON.stringify(config)).toBe(before); + expect(providerApiKeyQuotaMode("cloud-copy", { ...provider, baseUrl: "https://ollama.example.invalid" })).toBe("unsupported"); + }); + + test("canonical Kimi keys retain the default key auth mode when authMode is omitted", async () => { + const provider = { adapter: "openai-chat", baseUrl: "https://api.kimi.com/coding/v1", apiKey: "fixture-kimi-key" }; + const config: OcxConfig = { port: 0, defaultProvider: "coding-alias", providers: { "coding-alias": provider } }; + expect(providerApiKeyQuotaMode("coding-alias", provider)).toBe("probe"); + let calls = 0; + globalThis.fetch = (async (input, init) => { + calls++; + expect(String(input)).toBe("https://api.kimi.com/coding/v1/usages"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer fixture-kimi-key"); + return Response.json({ usage: { limit: 100, used: 25 } }); + }) as typeof globalThis.fetch; + const [row] = await fetchProviderApiKeyQuotas(config, "coding-alias"); + expect(row?.quota?.weeklyPercent).toBe(25); + expect(row?.isCurrent()).toBe(true); + expect(calls).toBe(1); + expect(providerApiKeyQuotaMode("coding-alias", { ...provider, authMode: "forward" })).toBe("unsupported"); + }); + + test("key probe failure drops last-good quota that expires during the upstream await", async () => { + const config = quotaKeyConfig(1); + const realDateNow = Date.now; + const observedAt = realDateNow(); + let now = observedAt; + let calls = 0; + globalThis.fetch = (async () => { + calls++; + if (calls === 1) return keyQuotaResponse(25); + expect(now).toBe(observedAt + 29 * 60_000 + 59_000); + now = observedAt + 30 * 60_000; + return new Response("{}", { status: 429 }); + }) as typeof globalThis.fetch; + Date.now = () => now; + try { + expect((await fetchProviderApiKeyQuotas(config, "openrouter"))[0]?.quota?.updatedAt).toBe(observedAt); + now = observedAt + 29 * 60_000 + 59_000; + const [failed] = await fetchProviderApiKeyQuotas(config, "openrouter", true); + expect(calls).toBe(2); + expect(failed?.quota).toBeNull(); + expect(failed?.unavailable).toBe(true); + } finally { + Date.now = realDateNow; + } + }); + + test("a recent failed key probe cannot extend a last-good measurement past thirty minutes", async () => { + const config = quotaKeyConfig(1); + const realDateNow = Date.now; + const observedAt = realDateNow(); + let now = observedAt; + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return calls === 1 ? keyQuotaResponse(25) : new Response("{}", { status: 429 }); + }) as typeof globalThis.fetch; + Date.now = () => now; + try { + const [initial] = await fetchProviderApiKeyQuotas(config, "openrouter"); + expect(initial?.quota?.updatedAt).toBe(observedAt); + now = observedAt + 29 * 60_000 + 59_000; + const [failed] = await fetchProviderApiKeyQuotas(config, "openrouter", true); + expect(failed?.unavailable).toBe(true); + expect(failed?.quota?.updatedAt).toBe(observedAt); + expect(calls).toBe(2); + // The attempt is only one second old, but the measurement has reached its bound. + now += 1_000; + const [expired] = await fetchProviderApiKeyQuotas(config, "openrouter"); + expect(expired?.quota).toBeNull(); + expect(expired?.unavailable).toBe(true); + expect(calls).toBe(3); + now += 1; + expect((await fetchProviderApiKeyQuotas(config, "openrouter"))[0]?.quota).toBeNull(); + expect(calls).toBe(3); // Negative caching still works once the old measurement is gone. + } finally { + Date.now = realDateNow; + } + }); + + test("capability uses all existing key reader destinations without resolving secrets", () => { + const targets = [ + ["coding-alias", "https://api.kimi.com/coding/v1"], + ["commandcode", "https://api.commandcode.ai/provider/v1"], + ["go-alias", "https://opencode.ai/zen/go/v1"], + ["a6-alias", "https://api.a6api.com/v1"], + ["openrouter", "https://openrouter.ai/api/v1"], + ["deepseek", "https://api.deepseek.com/v1"], + ["cline-pass", "https://api.cline.bot"], + ["glm-cn", "https://open.bigmodel.cn/api/coding/paas/v4"], + ["minimax", "https://api.minimax.io/v1"], + ["moonshot", "https://api.moonshot.ai/v1"], + ["venice", "https://api.venice.ai/api/v1"], + ["synthetic", "https://api.synthetic.new/v2"], + ["deepinfra", "https://api.deepinfra.com/v1/openai"], + ["neuralwatt", "https://api.neuralwatt.com/v1"], + ] as const; + for (const [name, baseUrl] of targets) { + expect(providerApiKeyQuotaMode(name, { baseUrl, adapter: "openai-chat", authMode: "key" })).toBe("probe"); + expect(providerApiKeyQuotaMode(name, { baseUrl, adapter: "openai-chat", authMode: "key", disabled: true })).toBe("unsupported"); + } + }); + + test("env-reference replacement cannot reuse a quota or fall back to the active key", async () => { + const envName = "OCX_QUOTA_KEY_FIXTURE"; + const previous = process.env[envName]; + const config = quotaKeyConfig(2); + config.providers.openrouter!.apiKeyPool![1]!.key = `$${envName}`; + const seen: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + seen.push(new Headers(init?.headers).get("authorization") ?? ""); + return keyQuotaResponse(44); + }) as typeof globalThis.fetch; + try { + process.env[envName] = "env-first-fixture"; + const first = await fetchProviderApiKeyQuotas(config, "openrouter"); + process.env[envName] = "env-second-fixture"; + expect(first[1]!.isCurrent()).toBe(false); + await fetchProviderApiKeyQuotas(config, "openrouter"); + expect(seen).toEqual(["Bearer fixture-key-0", "Bearer env-first-fixture", "Bearer env-second-fixture"]); + delete process.env[envName]; + const missing = await fetchProviderApiKeyQuotas(config, "openrouter"); + expect(missing[1]?.quota).toBeNull(); + expect(missing[1]?.unavailable).toBe(true); + expect(seen).toHaveLength(3); + } finally { + if (previous === undefined) delete process.env[envName]; + else process.env[envName] = previous; + } + }); + + test("key quota cache is bounded and a different provider cannot reuse the same slot", async () => { + const config = quotaKeyConfig(257); + let calls = 0; + globalThis.fetch = (async () => { calls++; return keyQuotaResponse(11); }) as typeof globalThis.fetch; + await fetchProviderApiKeyQuotas(config, "openrouter"); + expect(calls).toBe(257); + config.providers.openrouter!.apiKeyPool = [config.providers.openrouter!.apiKeyPool![0]!]; + await fetchProviderApiKeyQuotas(config, "openrouter"); + expect(calls).toBe(258); + config.providers.synthetic = { ...config.providers.openrouter!, baseUrl: "https://api.synthetic.new/v2" }; + await fetchProviderApiKeyQuotas(config, "synthetic"); + expect(calls).toBe(259); + }); + + test("isolates inactive keys and leaves provider reports, config and active key unchanged", async () => { + const config = quotaKeyConfig(); + const before = JSON.stringify(config); + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const auth = new Headers(init?.headers).get("authorization"); + return keyQuotaResponse(auth === "Bearer fixture-key-0" ? 10 : 75); + }) as typeof globalThis.fetch; + const current = await fetchProviderQuotaReports(config, true); + const rows = await fetchProviderApiKeyQuotas(config, "openrouter"); + expect(rows.map(row => row.quota?.customWindows?.[0]?.percent)).toEqual([10, 75]); + expect(rows.every(row => row.isCurrent())).toBe(true); + expect(Object.keys(rows[0]!)).toEqual(["keyId", "quota"]); + expect(JSON.stringify(rows)).not.toContain("fixture-key"); + expect(JSON.stringify(rows)).not.toContain("isCurrent"); + expect(JSON.stringify(config)).toBe(before); + expect(await fetchProviderQuotaReports(config)).toEqual(current); + }); + + test("same-id replacement and clear invalidate nonenumerable current guards", async () => { + const config = quotaKeyConfig(1); + globalThis.fetch = (async () => keyQuotaResponse(25)) as typeof globalThis.fetch; + const [first] = await fetchProviderApiKeyQuotas(config, "openrouter"); + expect(first!.isCurrent()).toBe(true); + config.providers.openrouter!.apiKeyPool![0]!.key = "replacement-fixture"; + expect(first!.isCurrent()).toBe(false); + const [replacement] = await fetchProviderApiKeyQuotas(config, "openrouter"); + expect(replacement!.isCurrent()).toBe(true); + clearProviderQuotaCache(); + expect(replacement!.isCurrent()).toBe(false); + }); + + test("force bypasses settled success/failure TTL but joins same-identity in-flight work", async () => { + const config = quotaKeyConfig(1); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let started!: () => void; + const entered = new Promise(resolve => { started = resolve; }); + let calls = 0; + let status = 200; + globalThis.fetch = (async () => { + calls += 1; + started(); + await gate; + return status === 200 ? keyQuotaResponse(10) : new Response("{}", { status }); + }) as typeof globalThis.fetch; + const first = fetchProviderApiKeyQuotas(config, "openrouter"); + await entered; + const forced = fetchProviderApiKeyQuotas(config, "openrouter", true); + release(); + await Promise.all([first, forced]); + expect(calls).toBe(1); + await fetchProviderApiKeyQuotas(config, "openrouter"); + expect(calls).toBe(1); + status = 429; + const [failed] = await fetchProviderApiKeyQuotas(config, "openrouter", true); + expect(failed?.unavailable).toBe(true); + expect(failed?.quota?.customWindows?.[0]?.percent).toBe(10); + await fetchProviderApiKeyQuotas(config, "openrouter"); + expect(calls).toBe(2); + status = 401; + expect((await fetchProviderApiKeyQuotas(config, "openrouter", true))[0]?.quota).toBeNull(); + status = 200; + expect((await fetchProviderApiKeyQuotas(config, "openrouter", true))[0]?.unavailable).toBeUndefined(); + expect(calls).toBe(4); + }); + + test("four workers bound a key roster and late removed rows cannot publish", async () => { + const config = quotaKeyConfig(7); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let fourStarted!: () => void; + const entered = new Promise(resolve => { fourStarted = resolve; }); + let active = 0; + let peak = 0; + let calls = 0; + globalThis.fetch = (async () => { + active += 1; calls += 1; peak = Math.max(peak, active); + if (calls === 4) fourStarted(); + await gate; + active -= 1; + return keyQuotaResponse(33); + }) as typeof globalThis.fetch; + const pending = fetchProviderApiKeyQuotas(config, "openrouter"); + await entered; + config.providers.openrouter!.apiKeyPool = config.providers.openrouter!.apiKeyPool!.filter(row => row.id !== "slot-0"); + release(); + const rows = await pending; + expect(peak).toBe(4); + expect(rows).toHaveLength(7); + expect(rows[0]!.isCurrent()).toBe(false); + expect(rows[0]!.quota).toBeNull(); + }); + + test("unsupported modes/destinations never fetch and capability does not need a key", async () => { + const config = quotaKeyConfig(); + const provider = config.providers.openrouter!; + delete provider.apiKey; + expect(providerApiKeyQuotaMode("openrouter", provider)).toBe("probe"); + globalThis.fetch = (async () => { throw new Error("unexpected upstream"); }) as typeof globalThis.fetch; + for (const authMode of ["oauth", "forward", "local"] as const) { + provider.authMode = authMode; + expect(providerApiKeyQuotaMode("openrouter", provider)).toBe("unsupported"); + expect(await fetchProviderApiKeyQuotas(config, "openrouter")).toEqual([]); + } + provider.authMode = "key"; + provider.baseUrl = "https://openrouter.example.invalid/api/v1"; + expect(providerApiKeyQuotaMode("openrouter", provider)).toBe("unsupported"); + expect(await fetchProviderApiKeyQuotas(config, "openrouter")).toEqual([]); + }); +}); 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..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 = { @@ -117,7 +122,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 +167,62 @@ 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 => { + 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/server/provider-account-quota-routes.test.ts b/tests/server/provider-account-quota-routes.test.ts new file mode 100644 index 0000000000..f6bf9a2329 --- /dev/null +++ b/tests/server/provider-account-quota-routes.test.ts @@ -0,0 +1,216 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleOauthAccountRoutes } from "../../src/server/management/oauth-account-routes"; +import { clearAccountQuotaCache, clearProviderQuotaCache } from "../../src/providers/quota"; +import * as quotaApi from "../../src/providers/quota"; +import { getAccountSet, saveCredential } from "../../src/oauth/store"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const originalFetch = globalThis.fetch; +const originalHome = process.env.OPENCODEX_HOME; +const originalFixtureKey = process.env.OCX_QUOTA_ROW_FIXTURE; +let home = ""; +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-quota-rows-")); + process.env.OPENCODEX_HOME = home; + clearAccountQuotaCache(); + clearProviderQuotaCache(); +}); +afterEach(() => { + globalThis.fetch = originalFetch; + clearAccountQuotaCache(); + clearProviderQuotaCache(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + if (originalFixtureKey === undefined) delete process.env.OCX_QUOTA_ROW_FIXTURE; + else process.env.OCX_QUOTA_ROW_FIXTURE = originalFixtureKey; + removeTreeWithRetry(home); +}); + +function keyConfig(): OcxConfig { + return { port: 0, defaultProvider: "openrouter", providers: { openrouter: { + adapter: "openai-chat", authMode: "key", baseUrl: "https://openrouter.ai/api/v1", + apiKey: "fixture-first", apiKeyPool: [ + { id: "first", key: "fixture-first" }, { id: "second", key: "fixture-second" }, + ], + } } }; +} + +async function read(path: string, config: OcxConfig) { + const req = new Request(`http://localhost${path}`); + const response = await handleOauthAccountRoutes({ + req, url: new URL(req.url), config, version: "test", deps: {}, + convergeCodexCatalog: async () => ({ status: "failed", reason: "disk" }), + syncClaudeAgentDefsBestEffort: async () => {}, + }); + if (!response) throw new Error("unhandled fixture route"); + return response; +} + +test("cheap and refresh-only key lists advertise capability without probing or changing config", async () => { + const config = keyConfig(); + const before = JSON.stringify(config); + let probes = 0; + globalThis.fetch = (async () => { probes++; throw new Error("unexpected quota request"); }) as typeof fetch; + for (const suffix of ["", "&refresh=1"]) { + const response = await read(`/api/providers/keys?name=openrouter${suffix}`, config); + const body = await response.json(); + expect(body.keys.map((row: { quotaMode: string }) => row.quotaMode)).toEqual(["probe", "probe"]); + expect(body.keys.every((row: { quota?: unknown }) => row.quota === undefined)).toBe(true); + } + expect(probes).toBe(0); + expect(JSON.stringify(config)).toBe(before); +}); + +test("enriched key rows retain per-key values without activating or exposing credentials", async () => { + const config = keyConfig(); + const before = JSON.stringify(config); + const tokens: string[] = []; + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe("https://openrouter.ai/api/v1/key"); + const token = new Headers(init?.headers).get("authorization")!; + tokens.push(token); + return Response.json({ data: { limit: 100, limit_remaining: token.endsWith("first") ? 80 : 40 } }); + }) as typeof fetch; + const body = await (await read("/api/providers/keys?name=openrouter"a=1&refresh=1", config)).json(); + expect(body.activeId).toBe("first"); + expect(body.keys.map((row: { quota: { customWindows: { percent: number }[] } }) => row.quota.customWindows[0]?.percent)).toEqual([20, 60]); + expect(tokens.sort()).toEqual(["Bearer fixture-first", "Bearer fixture-second"]); + expect(body.keys.every((row: { quotaMode: string; quotaUnavailable: boolean }) => row.quotaMode === "probe" && row.quotaUnavailable === false)).toBe(true); + const wire = JSON.stringify(body); + expect(wire).not.toContain("fixture-first"); + expect(wire).not.toContain("fixture-second"); + expect(wire).not.toContain("isCurrent"); + expect(JSON.stringify(config)).toBe(before); +}); + +test("a removed key cannot return in an in-flight enrichment response", async () => { + const config = keyConfig(); + globalThis.fetch = (async (_input, init) => { + if (new Headers(init?.headers).get("authorization")?.endsWith("second")) { + config.providers.openrouter.apiKeyPool = config.providers.openrouter.apiKeyPool!.filter(row => row.id !== "second"); + } + return Response.json({ data: { limit: 100, limit_remaining: 50 } }); + }) as typeof fetch; + const body = await (await read("/api/providers/keys?name=openrouter"a=1", config)).json(); + expect(body.keys.map((row: { id: string }) => row.id)).toEqual(["first"]); +}); + +test("unsupported key destinations do not resolve or probe credentials", async () => { + const config = keyConfig(); + config.providers.openrouter.baseUrl = "https://quota-fixture.invalid/api/v1"; + let probes = 0; + globalThis.fetch = (async () => { probes++; throw new Error("must not probe"); }) as typeof fetch; + const body = await (await read("/api/providers/keys?name=openrouter"a=1&refresh=1", config)).json(); + expect(body.keys.every((row: { quotaMode: string; quota?: unknown }) => row.quotaMode === "unsupported" && row.quota === undefined)).toBe(true); + expect(probes).toBe(0); +}); + +test("same-id environment credential replacement cannot inherit a settled old quota", async () => { + const config = keyConfig(); + process.env.OCX_QUOTA_ROW_FIXTURE = "fixture-old"; + config.providers.openrouter.apiKey = "${OCX_QUOTA_ROW_FIXTURE}"; + config.providers.openrouter.apiKeyPool = [{ id: "same-id", key: "${OCX_QUOTA_ROW_FIXTURE}" }]; + globalThis.fetch = (async () => { + const response = Response.json({ data: { limit: 100, limit_remaining: 80 } }); + process.env.OCX_QUOTA_ROW_FIXTURE = "fixture-new"; + return response; + }) as typeof fetch; + const body = await (await read("/api/providers/keys?name=openrouter"a=1", config)).json(); + expect(body.keys[0].id).toBe("same-id"); + expect(body.keys[0].quota).toBeNull(); + expect(body.keys[0].quotaUnavailable).toBe(true); +}); + +test("the final route projection checks private identity even for a non-null completed reading", async () => { + const config = keyConfig(); + let checked = 0; + const probe = spyOn(quotaApi, "fetchProviderApiKeyQuotas").mockResolvedValue([{ + keyId: "first", quota: { weeklyPercent: 77, updatedAt: Date.now() }, + isCurrent: () => { checked++; return false; }, + }]); + try { + const body = await (await read("/api/providers/keys?name=openrouter"a=1", config)).json(); + expect(checked).toBe(1); + expect(body.keys[0].quota).toBeNull(); + expect(body.keys[0].quotaUnavailable).toBe(true); + expect(JSON.stringify(body)).not.toContain("weeklyPercent"); + expect(JSON.stringify(body)).not.toContain("isCurrent"); + } finally { + probe.mockRestore(); + } +}); + +test("OAuth cheap rows carry probe mode and enriched rows clear stale failure flags", async () => { + await saveCredential("anthropic", { access: "fixture-access", refresh: "fixture-refresh", expires: Date.now() + 3600000, accountId: "fixture-upstream" }); + const config: OcxConfig = { port: 0, defaultProvider: "anthropic", providers: { anthropic: { adapter: "anthropic", authMode: "oauth", baseUrl: "https://api.anthropic.com" } } }; + const before = getAccountSet("anthropic")?.activeAccountId; + let fail = true; + let probes = 0; + globalThis.fetch = (async () => { + probes++; + return fail ? new Response(null, { status: 503 }) : Response.json({ five_hour: { utilization: 0 }, seven_day: { utilization: 12 } }); + }) as typeof fetch; + const plain = await (await read("/api/oauth/accounts?provider=anthropic", config)).json(); + expect(plain.accounts[0].quotaMode).toBe("probe"); + expect(probes).toBe(0); + const failed = await (await read("/api/oauth/accounts?provider=anthropic"a=1&refresh=1", config)).json(); + expect(failed.accounts[0].quotaUnavailable).toBe(true); + fail = false; + const ok = await (await read("/api/oauth/accounts?provider=anthropic"a=1&refresh=1", config)).json(); + expect(ok.accounts[0].quotaUnavailable).toBe(false); + expect(ok.accounts[0].quota.fiveHourPercent).toBe(0); + expect(getAccountSet("anthropic")?.activeAccountId).toBe(before); +}); + +test("OAuth final projection rejects quota from a replaced stored identity", async () => { + await saveCredential("anthropic", { access: "fixture-access", refresh: "fixture-refresh", expires: Date.now() + 3600000, accountId: "fixture-upstream" }); + const accountId = getAccountSet("anthropic")!.activeAccountId; + const config: OcxConfig = { port: 0, defaultProvider: "anthropic", providers: { anthropic: { adapter: "anthropic", authMode: "oauth", baseUrl: "https://api.anthropic.com" } } }; + let checked = 0; + const probe = spyOn(quotaApi, "fetchProviderAccountQuotas").mockResolvedValue([{ + accountId, quota: { weeklyPercent: 77, updatedAt: Date.now() }, + isCurrent: () => { checked++; return false; }, + }]); + try { + const body = await (await read("/api/oauth/accounts?provider=anthropic"a=1", config)).json(); + expect(checked).toBe(1); + expect(body.accounts[0].quota).toBeNull(); + expect(body.accounts[0].quotaUnavailable).toBe(true); + } finally { + probe.mockRestore(); + } +}); + +test("OAuth final projection rejects a replaced configured provider object", async () => { + await saveCredential("anthropic", { access: "fixture-access", refresh: "fixture-refresh", expires: Date.now() + 3600000, accountId: "fixture-upstream" }); + const accountId = getAccountSet("anthropic")!.activeAccountId; + const config: OcxConfig = { port: 0, defaultProvider: "anthropic", providers: { anthropic: { adapter: "anthropic", authMode: "oauth", baseUrl: "https://api.anthropic.com" } } }; + const probe = spyOn(quotaApi, "fetchProviderAccountQuotas").mockImplementation(async () => { + config.providers.anthropic = { ...config.providers.anthropic, disabled: true }; + return [{ accountId, quota: { weeklyPercent: 77, updatedAt: Date.now() }, isCurrent: () => true }]; + }); + try { + const body = await (await read("/api/oauth/accounts?provider=anthropic"a=1", config)).json(); + expect(body.accounts[0].quota).toBeNull(); + expect(body.accounts[0].quotaUnavailable).toBe(true); + } finally { + probe.mockRestore(); + } +}); + +test("passive account refresh reports unobserved without sending a quota request", async () => { + await saveCredential("meta-muse", { access: "fixture-passive", refresh: "", expires: Date.now() + 3600000, accountId: "fixture-passive-account" }); + const config: OcxConfig = { port: 0, defaultProvider: "meta-muse", providers: { "meta-muse": { adapter: "openai-chat", authMode: "oauth", baseUrl: "https://example.invalid" } } }; + let requests = 0; + globalThis.fetch = (async () => { requests++; throw new Error("passive readers must not probe"); }) as typeof fetch; + const body = await (await read("/api/oauth/accounts?provider=meta-muse"a=1&refresh=1", config)).json(); + expect(body.accounts).toHaveLength(1); + expect(body.accounts[0].quotaMode).toBe("passive"); + expect(body.accounts[0].quota).toBeUndefined(); + expect(body.accounts[0].quotaUnavailable).toBeUndefined(); + expect(requests).toBe(0); +}); diff --git a/tests/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts index e3f4de9df5..cb02c25a6e 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: "/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" } })) + .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: "/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 }); + 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: /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("/synthetic-private/"); + 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 diff --git a/tests/usage/quota-reset-notify.test.ts b/tests/usage/quota-reset-notify.test.ts index e65f2507d3..6a503e69a5 100644 --- a/tests/usage/quota-reset-notify.test.ts +++ b/tests/usage/quota-reset-notify.test.ts @@ -307,6 +307,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({ @@ -562,6 +577,10 @@ 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 { if (receiveTimeout !== undefined) clearTimeout(receiveTimeout); globalThis.fetch = realFetch; 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");