@iislee/opencodex 2.11.0 → 2.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +109 -0
- package/README.md +114 -19
- package/bin/ocx.mjs +164 -36
- package/bin/package-main.mjs +1 -1
- package/gui/dist/assets/index-BF38heuV.js +104 -0
- package/gui/dist/assets/index-DMiI18Kv.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/alibaba-color.svg +1 -1
- package/gui/dist/provider-icons/antigravity-color.svg +1 -1
- package/gui/dist/provider-icons/claude-color.svg +1 -1
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/commandcode-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -1
- package/gui/dist/provider-icons/cursor-color.svg +1 -1
- package/gui/dist/provider-icons/deepseek-color.svg +1 -1
- package/gui/dist/provider-icons/firepass-color.svg +1 -1
- package/gui/dist/provider-icons/fireworks-color.svg +1 -1
- package/gui/dist/provider-icons/gemini-color.svg +1 -1
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
- package/gui/dist/provider-icons/grok.svg +1 -1
- package/gui/dist/provider-icons/groq-color.svg +1 -1
- package/gui/dist/provider-icons/huggingface-color.svg +1 -1
- package/gui/dist/provider-icons/kimi-color.svg +1 -1
- package/gui/dist/provider-icons/kiro-color.svg +2 -2
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
- package/gui/dist/provider-icons/mistral-color.svg +1 -1
- package/gui/dist/provider-icons/moonshot-color.svg +1 -1
- package/gui/dist/provider-icons/nvidia-color.svg +1 -1
- package/gui/dist/provider-icons/ollama-color.svg +1 -1
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/gui/dist/provider-icons/opencode.svg +2 -1
- package/gui/dist/provider-icons/openrouter-color.svg +1 -1
- package/gui/dist/provider-icons/pi.svg +2 -2
- package/gui/dist/provider-icons/qianfan-color.svg +1 -1
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/vllm-color.svg +1 -1
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
- package/package.json +19 -10
- package/src/adapters/anthropic-output-schema.ts +137 -0
- package/src/adapters/anthropic.ts +376 -52
- package/src/adapters/base.ts +54 -7
- package/src/adapters/client-fingerprint.ts +18 -12
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
- package/src/adapters/command-code.ts +601 -0
- package/src/adapters/cursor/checkpoint-store.ts +303 -0
- package/src/adapters/cursor/cursor-errors.ts +129 -6
- package/src/adapters/cursor/discovery.ts +61 -4
- package/src/adapters/cursor/effort-map.ts +27 -3
- package/src/adapters/cursor/framing.ts +39 -0
- package/src/adapters/cursor/h2-pool.ts +123 -0
- package/src/adapters/cursor/http1-bidi.ts +361 -0
- package/src/adapters/cursor/images.ts +704 -0
- package/src/adapters/cursor/live-models.ts +174 -58
- package/src/adapters/cursor/live-transport.ts +609 -170
- package/src/adapters/cursor/native-exec-common.ts +23 -2
- package/src/adapters/cursor/native-exec-fs.ts +10 -7
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +0 -1
- package/src/adapters/cursor/native-exec.ts +101 -14
- package/src/adapters/cursor/protobuf-events.ts +829 -11
- package/src/adapters/cursor/protobuf-request.ts +383 -65
- package/src/adapters/cursor/request-builder.ts +237 -31
- package/src/adapters/cursor/tool-definitions.ts +242 -12
- package/src/adapters/cursor/tool-result-normalize.ts +92 -0
- package/src/adapters/cursor/transport.ts +22 -0
- package/src/adapters/cursor/types.ts +28 -1
- package/src/adapters/cursor.ts +133 -7
- package/src/adapters/google-antigravity-replay.ts +558 -34
- package/src/adapters/google-antigravity-wire.ts +43 -10
- package/src/adapters/google-http.ts +38 -10
- package/src/adapters/google-tool-schema.ts +80 -15
- package/src/adapters/google-truncation.ts +11 -0
- package/src/adapters/google.ts +618 -74
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +18 -2
- package/src/adapters/kiro-tools.ts +10 -1
- package/src/adapters/kiro.ts +92 -54
- package/src/adapters/mimo-free.ts +17 -0
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +1311 -336
- package/src/adapters/openai-responses-url.ts +16 -0
- package/src/adapters/openai-responses.ts +830 -56
- package/src/adapters/registry.ts +175 -0
- package/src/adapters/responses-tool-schema.ts +67 -0
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +104 -21
- package/src/adapters/xai-web-search.ts +185 -0
- package/src/bridge.ts +428 -61
- package/src/chat/inbound.ts +43 -19
- package/src/chat/outbound.ts +82 -26
- package/src/claude/agents-inject.ts +32 -9
- package/src/claude/context-windows.ts +21 -5
- package/src/claude/desktop-3p.ts +243 -9
- package/src/claude/gateway-cache.ts +41 -4
- package/src/claude/inbound.ts +72 -3
- package/src/claude/model-info.ts +38 -15
- package/src/claude/outbound.ts +70 -16
- package/src/cli/account-api.ts +35 -1
- package/src/cli/account-auth.ts +33 -6
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +389 -2
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +30 -6
- package/src/cli/agent.ts +45 -1
- package/src/cli/claude-agent-startup-sync.ts +73 -0
- package/src/cli/claude-desktop.ts +29 -4
- package/src/cli/claude.ts +103 -34
- package/src/cli/codex-log-guard-doctor.ts +103 -0
- package/src/cli/codex-shim-autorestore.ts +2 -0
- package/src/cli/codex-shim-readiness.ts +76 -0
- package/src/cli/combo.ts +8 -0
- package/src/cli/config-command.ts +74 -10
- package/src/cli/dispatch.ts +593 -0
- package/src/cli/doctor.ts +315 -43
- package/src/cli/ensure-desired-integrations.ts +152 -0
- package/src/cli/export-command.ts +46 -20
- package/src/cli/help.ts +20 -271
- package/src/cli/index.ts +378 -519
- package/src/cli/init.ts +4 -17
- package/src/cli/integrations.ts +120 -2
- package/src/cli/lab.ts +607 -0
- package/src/cli/launcher-context.ts +77 -0
- package/src/cli/minimax.ts +497 -0
- package/src/cli/models-runtime.ts +35 -2
- package/src/cli/models.ts +100 -14
- package/src/cli/observe.ts +92 -3
- package/src/cli/opencode.ts +4 -2
- package/src/cli/provider-runtime.ts +18 -1
- package/src/cli/provider.ts +24 -3
- package/src/cli/ready.ts +301 -0
- package/src/cli/registry.ts +437 -0
- package/src/cli/root.ts +86 -0
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +6 -3
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +10 -3
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/cli/v2.ts +105 -10
- package/src/cli.ts +1 -1
- package/src/clients/config-export.ts +1358 -21
- package/src/codex/account-label.ts +14 -1
- package/src/codex/account-lifecycle.ts +130 -13
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/account-store.ts +29 -2
- package/src/codex/account-usability.ts +25 -2
- package/src/codex/admission.ts +256 -0
- package/src/codex/affinity-debug.ts +162 -0
- package/src/codex/app-server-processes.ts +493 -106
- package/src/codex/app-server-restart-service.ts +232 -0
- package/src/codex/auth-api.ts +849 -242
- package/src/codex/auth-collision.ts +5 -3
- package/src/codex/auth-context.ts +345 -32
- package/src/codex/autostart-health.ts +8 -1
- package/src/codex/catalog/account-models.ts +67 -0
- package/src/codex/catalog/aggregation.ts +68 -10
- package/src/codex/catalog/bundled.ts +331 -33
- package/src/codex/catalog/effort.ts +121 -30
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +529 -45
- package/src/codex/catalog/native-models.ts +72 -0
- package/src/codex/catalog/parsing.ts +224 -30
- package/src/codex/catalog/provider-fetch.ts +1460 -134
- package/src/codex/catalog/sync.ts +1449 -186
- package/src/codex/catalog-admission.ts +199 -0
- package/src/codex/catalog-refresh-status.ts +105 -0
- package/src/codex/catalog-write-serialization.ts +242 -0
- package/src/codex/catalog.ts +6 -3
- package/src/codex/codex-write-lock.ts +384 -0
- package/src/codex/convergence-types.ts +614 -0
- package/src/codex/convergence.ts +651 -0
- package/src/codex/coordinator-doctor.ts +332 -0
- package/src/codex/custom-model-catalog-migration.ts +176 -0
- package/src/codex/desired-state.ts +230 -0
- package/src/codex/features.ts +636 -39
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +407 -0
- package/src/codex/history-lock.ts +242 -0
- package/src/codex/history-migration-guardian.ts +26 -20
- package/src/codex/history-provider.ts +231 -28
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +220 -0
- package/src/codex/inject-coordination.ts +290 -0
- package/src/codex/inject.ts +1073 -152
- package/src/codex/injected-marker.ts +37 -3
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +80 -0
- package/src/codex/journal.ts +66 -4
- package/src/codex/log-guard/inspect.ts +506 -0
- package/src/codex/log-guard/lock.ts +150 -0
- package/src/codex/log-guard/maintenance.ts +403 -0
- package/src/codex/log-guard/path-safety.ts +88 -0
- package/src/codex/log-guard/policy.ts +44 -0
- package/src/codex/log-guard/processes.ts +205 -0
- package/src/codex/log-guard/protection.ts +489 -0
- package/src/codex/log-guard/sqlite-errors.ts +9 -0
- package/src/codex/main-account-cache.ts +24 -0
- package/src/codex/main-account.ts +29 -1
- package/src/codex/management-convergence.ts +167 -0
- package/src/codex/model-cache.ts +56 -10
- package/src/codex/model-entitlements.ts +353 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +178 -0
- package/src/codex/native-main-lock-file.ts +162 -0
- package/src/codex/native-main-owner.ts +329 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1531 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +492 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +682 -0
- package/src/codex/paths.ts +80 -1
- package/src/codex/plan-from-token.ts +140 -0
- package/src/codex/plan.ts +40 -0
- package/src/codex/plugins-doctor.ts +1 -1
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/project-config-warnings.ts +20 -6
- package/src/codex/prompt-journal.ts +352 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/quota-rejection.ts +298 -0
- package/src/codex/quota.ts +175 -13
- package/src/codex/refresh.ts +11 -2
- package/src/codex/reset-credit-recovery.ts +1044 -0
- package/src/codex/routing.ts +505 -94
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +1009 -28
- package/src/codex/subagent-model-fallback.ts +350 -35
- package/src/codex/sync.ts +191 -2
- package/src/codex/transition-state.ts +612 -0
- package/src/codex/upstream-host-health.ts +368 -0
- package/src/codex/user-identity.ts +557 -0
- package/src/codex/warmup.ts +187 -81
- package/src/codex/write-coordination.ts +114 -0
- package/src/combos/failover.ts +20 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/request.ts +32 -0
- package/src/combos/types.ts +81 -9
- package/src/config/provider-name.ts +24 -0
- package/src/config.ts +1762 -140
- package/src/generated/compatibility-version.json +3164 -0
- package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
- package/src/grok/inject.ts +16 -5
- package/src/grok/inspect.ts +45 -0
- package/src/grok/sync.ts +2 -2
- package/src/images/loop.ts +152 -29
- package/src/images/plan.ts +23 -13
- package/src/integrations/config-io.ts +269 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/mutation-flight.ts +71 -0
- package/src/integrations/native/ownership-preflight.ts +202 -0
- package/src/integrations/omp-yaml-source.ts +358 -0
- package/src/integrations/owned-refresh.ts +74 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +159 -0
- package/src/integrations/serialize.ts +314 -0
- package/src/integrations/state.ts +361 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer-lock.ts +98 -0
- package/src/integrations/writer.ts +691 -0
- package/src/lab/artifacts/sanitize.ts +586 -0
- package/src/lab/artifacts/secure-fs.ts +475 -0
- package/src/lab/artifacts/store.ts +310 -0
- package/src/lab/automation/budgets.ts +78 -0
- package/src/lab/automation/config-persistence.ts +256 -0
- package/src/lab/automation/constants.ts +39 -0
- package/src/lab/automation/cooldown.ts +103 -0
- package/src/lab/automation/dispatch.ts +211 -0
- package/src/lab/automation/index.ts +13 -0
- package/src/lab/automation/orchestrator.ts +499 -0
- package/src/lab/automation/persistence.ts +512 -0
- package/src/lab/automation/planner.ts +371 -0
- package/src/lab/automation/policy.ts +136 -0
- package/src/lab/automation/queue.ts +191 -0
- package/src/lab/automation/recovery.ts +24 -0
- package/src/lab/automation/route-context.ts +21 -0
- package/src/lab/automation/run-key.ts +44 -0
- package/src/lab/automation/runs-query.ts +34 -0
- package/src/lab/automation/types.ts +160 -0
- package/src/lab/conformance/assertion.ts +325 -0
- package/src/lab/conformance/digest.ts +22 -0
- package/src/lab/conformance/executor.ts +741 -0
- package/src/lab/conformance/fixture-provider.ts +27 -0
- package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
- package/src/lab/conformance/harness-budget.ts +47 -0
- package/src/lab/conformance/index.ts +5 -0
- package/src/lab/conformance/jcs.ts +64 -0
- package/src/lab/conformance/json-pointer.ts +39 -0
- package/src/lab/conformance/manifest.ts +180 -0
- package/src/lab/conformance/mcp-stub.ts +179 -0
- package/src/lab/conformance/negative-controls.ts +164 -0
- package/src/lab/conformance/observation.ts +355 -0
- package/src/lab/conformance/runner.ts +68 -0
- package/src/lab/conformance/sse-normalize.ts +59 -0
- package/src/lab/conformance/suite-manifest.ts +78 -0
- package/src/lab/conformance/types.ts +214 -0
- package/src/lab/constants.ts +126 -0
- package/src/lab/digest.ts +64 -0
- package/src/lab/events/errors.ts +9 -0
- package/src/lab/events/limits.ts +117 -0
- package/src/lab/events/types.ts +229 -0
- package/src/lab/events/validate.ts +781 -0
- package/src/lab/fabric/constants.ts +40 -0
- package/src/lab/fabric/executor.ts +492 -0
- package/src/lab/fabric/index.ts +80 -0
- package/src/lab/fabric/manifest.ts +222 -0
- package/src/lab/fabric/observe.ts +489 -0
- package/src/lab/fabric/patch.ts +79 -0
- package/src/lab/fabric/producer-child.ts +139 -0
- package/src/lab/fabric/producer-isolate.ts +276 -0
- package/src/lab/fabric/producer-protocol.ts +61 -0
- package/src/lab/fabric/scratch.ts +439 -0
- package/src/lab/fabric/subject.ts +106 -0
- package/src/lab/fabric/types.ts +134 -0
- package/src/lab/fabric/verifier.ts +98 -0
- package/src/lab/index.ts +54 -0
- package/src/lab/ledger/artifact-refs.ts +127 -0
- package/src/lab/ledger/invalidation.ts +136 -0
- package/src/lab/ledger/purge.ts +310 -0
- package/src/lab/ledger/store.ts +532 -0
- package/src/lab/live/credential-lease.ts +53 -0
- package/src/lab/live/destination.ts +155 -0
- package/src/lab/live/executor.ts +336 -0
- package/src/lab/live/inert-tools.ts +56 -0
- package/src/lab/live/manifest.ts +85 -0
- package/src/lab/live/mcp-loopback.ts +57 -0
- package/src/lab/live/runner.ts +19 -0
- package/src/lab/live/sandbox.ts +61 -0
- package/src/lab/live/suite-manifest.ts +41 -0
- package/src/lab/live/transport.ts +118 -0
- package/src/lab/live/types.ts +197 -0
- package/src/lab/observe/from-conformance.ts +301 -0
- package/src/lab/observe/from-live.ts +117 -0
- package/src/lab/paths.ts +153 -0
- package/src/lab/projection/rebuild.ts +495 -0
- package/src/lab/projection/schema.ts +135 -0
- package/src/lab/projection/verdicts.ts +474 -0
- package/src/lab/projection/verification.ts +412 -0
- package/src/lab/public/bundle.ts +217 -0
- package/src/lab/public/community-authority.ts +175 -0
- package/src/lab/public/community-files.ts +29 -0
- package/src/lab/public/community.ts +479 -0
- package/src/lab/public/file-safety.ts +155 -0
- package/src/lab/public/ids.ts +26 -0
- package/src/lab/public/index.ts +16 -0
- package/src/lab/public/mutation-lock.ts +424 -0
- package/src/lab/public/operator.ts +353 -0
- package/src/lab/public/origin-purge.ts +79 -0
- package/src/lab/public/origin.ts +203 -0
- package/src/lab/public/privacy.ts +143 -0
- package/src/lab/public/private-file.ts +261 -0
- package/src/lab/public/project.ts +124 -0
- package/src/lab/public/purge-test-fault.ts +21 -0
- package/src/lab/public/purge.ts +223 -0
- package/src/lab/public/registry.ts +44 -0
- package/src/lab/public/revocation.ts +252 -0
- package/src/lab/public/signature.ts +243 -0
- package/src/lab/public/storage.ts +105 -0
- package/src/lab/public/strict-json.ts +206 -0
- package/src/lab/public/time.ts +26 -0
- package/src/lab/public/types.ts +172 -0
- package/src/lab/public/validate.ts +391 -0
- package/src/lab/query/catalog.ts +101 -0
- package/src/lab/query/connection.ts +107 -0
- package/src/lab/query/constants.ts +4 -0
- package/src/lab/query/cursor.ts +132 -0
- package/src/lab/query/dto-map.ts +277 -0
- package/src/lab/query/errors.ts +22 -0
- package/src/lab/query/freshness.ts +53 -0
- package/src/lab/query/index.ts +45 -0
- package/src/lab/query/latest-observation.ts +59 -0
- package/src/lab/query/passive-production.ts +159 -0
- package/src/lab/query/queries.ts +444 -0
- package/src/lab/query/types.ts +266 -0
- package/src/lab/subject/behavior-fingerprint.ts +77 -0
- package/src/lab/subject/installation-salt.ts +112 -0
- package/src/lab/subject/protocol-subject.ts +80 -0
- package/src/lab/subject/route-subject.ts +74 -0
- package/src/lib/app-owned-memory-stores.ts +22 -0
- package/src/lib/bounded-body.ts +153 -9
- package/src/lib/bun-runtime.ts +125 -12
- package/src/lib/bun-stream-caps.ts +13 -9
- package/src/lib/codex-restart-contract.ts +120 -0
- package/src/lib/config-ownership.ts +6 -2
- package/src/lib/destination-policy.ts +65 -1
- package/src/lib/errors.ts +44 -2
- package/src/lib/fabric-task-execution-authority.ts +7 -0
- package/src/lib/fabric-task-host.ts +29 -0
- package/src/lib/lab-activation.ts +223 -0
- package/src/lib/lab-live-execution-authority.ts +13 -0
- package/src/lib/lab-live-host.ts +30 -0
- package/src/lib/lab-live-pinned-sender.ts +56 -0
- package/src/lib/lab-live-route-production.ts +130 -0
- package/src/lib/lab-passive-linker-registration.ts +26 -0
- package/src/lib/local-management-attestation.ts +51 -0
- package/src/lib/local-management-capability.ts +100 -0
- package/src/lib/local-provider-reload-contract.ts +100 -0
- package/src/lib/optional-shutdown-hooks.ts +57 -0
- package/src/lib/pinned-http.ts +145 -26
- package/src/lib/process-control.ts +4 -1
- package/src/lib/provider-outbound.ts +49 -9
- package/src/lib/redact.ts +419 -3
- package/src/lib/self-launch-argv.ts +15 -0
- package/src/lib/server-resource-ownership.ts +71 -0
- package/src/lib/shadow-call.ts +35 -4
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/state-store-registrations.ts +10 -2
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/token-estimate.ts +19 -2
- package/src/lib/tool-argument-integers.ts +202 -0
- package/src/lib/translator-budget.ts +44 -0
- package/src/lib/upstream-http-version.ts +57 -0
- package/src/lib/upstream-reachability.ts +95 -0
- package/src/lib/upstream-retry.ts +156 -3
- package/src/lib/windows-atomic-replace.ts +155 -0
- package/src/lib/windows-elevation.ts +70 -2
- package/src/lib/windows-secret-acl.ts +409 -69
- package/src/lib/windows-service-wrappers.ts +72 -0
- package/src/lib/windows-text.ts +106 -0
- package/src/lib/windows-user-principal.ts +341 -0
- package/src/lib/winsw.ts +33 -5
- package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
- package/src/oauth/account-import/index.ts +15 -0
- package/src/oauth/account-import/parser.ts +83 -0
- package/src/oauth/account-import/registry.ts +18 -0
- package/src/oauth/account-import/service.ts +75 -0
- package/src/oauth/account-import/types.ts +91 -0
- package/src/oauth/anthropic.ts +12 -1
- package/src/oauth/callback-server.ts +8 -2
- package/src/oauth/chatgpt.ts +12 -1
- package/src/oauth/command-code.ts +239 -0
- package/src/oauth/cursor.ts +46 -5
- package/src/oauth/google-antigravity.ts +35 -3
- package/src/oauth/health.ts +20 -12
- package/src/oauth/index.ts +398 -66
- package/src/oauth/key-providers.ts +16 -0
- package/src/oauth/kimi.ts +16 -2
- package/src/oauth/kiro.ts +50 -6
- package/src/oauth/local-token-detect.ts +11 -2
- package/src/oauth/log.ts +3 -1
- package/src/oauth/login-cli.ts +88 -28
- package/src/oauth/nous.ts +798 -0
- package/src/oauth/store.ts +119 -21
- package/src/oauth/token-guardian.ts +9 -3
- package/src/pi/models.ts +2 -2
- package/src/providers/alibaba-region-migration.ts +1 -1
- package/src/providers/antigravity-models.ts +521 -31
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/codex-capacity.ts +292 -0
- package/src/providers/command-code-efforts.ts +144 -0
- package/src/providers/context-cap.ts +22 -5
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/derive.ts +253 -6
- package/src/providers/fastwire.ts +501 -0
- package/src/providers/free-directory.ts +10 -7
- package/src/providers/google-vertex-location.ts +14 -0
- package/src/providers/key-failover.ts +71 -3
- package/src/providers/label.ts +1 -1
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +115 -22
- package/src/providers/model-rename-migration.ts +255 -0
- package/src/providers/model-rename-startup.ts +28 -0
- package/src/providers/openai-sidecar.ts +72 -4
- package/src/providers/openai-tier-startup.ts +31 -2
- package/src/providers/openai-tiers.ts +119 -4
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +29 -0
- package/src/providers/quota.ts +1319 -38
- package/src/providers/registry.ts +1429 -111
- package/src/providers/request-pacing.ts +310 -0
- package/src/providers/service-tier.ts +277 -0
- package/src/providers/slug-codec.ts +42 -6
- package/src/providers/static-model-discovery.ts +86 -0
- package/src/providers/xai-responses-opt-in.ts +15 -0
- package/src/providers/xai-transport.ts +11 -4
- package/src/reasoning-effort.ts +49 -1
- package/src/responses/compaction.ts +26 -1
- package/src/responses/custom-tool-compat.ts +266 -0
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/namespace-tool-compat.ts +355 -0
- package/src/responses/parser.ts +220 -38
- package/src/responses/provider-continuation.ts +98 -0
- package/src/responses/provider-opaque-metadata.ts +73 -0
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/reasoning-replay-cache.ts +426 -0
- package/src/responses/schema.ts +7 -1
- package/src/responses/spill-store.ts +75 -10
- package/src/responses/state.ts +565 -27
- package/src/responses/thought-signature-replay.ts +347 -0
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/responses/truncated-stop-reason.ts +60 -0
- package/src/router.ts +366 -30
- package/src/routing/analytics.ts +378 -0
- package/src/routing/capability.ts +244 -0
- package/src/routing/compatibility/assemble.ts +73 -0
- package/src/routing/compatibility/behavior.ts +278 -0
- package/src/routing/compatibility/catalog.ts +99 -0
- package/src/routing/compatibility/endpoint.ts +52 -0
- package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
- package/src/routing/compatibility/policy.ts +181 -0
- package/src/routing/compatibility/provider-slot.ts +56 -0
- package/src/routing/compatibility/reader.ts +110 -0
- package/src/routing/compatibility/subject.ts +191 -0
- package/src/routing/compatibility/types.ts +64 -0
- package/src/routing/compatibility/version.ts +104 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +495 -0
- package/src/routing/health.ts +412 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +605 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +547 -0
- package/src/routing/quota.ts +145 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +776 -0
- package/src/server/adapter-resolve.ts +2 -29
- package/src/server/auth-cors.ts +267 -46
- package/src/server/background-lifecycle.ts +182 -0
- package/src/server/chat-completions.ts +130 -56
- package/src/server/chat-native-sse.ts +331 -0
- package/src/server/chat-native.ts +426 -0
- package/src/server/claude-messages.ts +159 -43
- package/src/server/direct-local-http.ts +347 -0
- package/src/server/effort-policy.ts +18 -0
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/gui-static.ts +39 -10
- package/src/server/images.ts +94 -12
- package/src/server/index.ts +865 -181
- package/src/server/lifecycle.ts +284 -13
- package/src/server/live.ts +136 -17
- package/src/server/local-management-read-client.ts +90 -0
- package/src/server/local-provider-reload-client.ts +137 -0
- package/src/server/management/agent-settings-routes.ts +398 -103
- package/src/server/management/api-key-usage.ts +31 -5
- package/src/server/management/body.ts +6 -0
- package/src/server/management/combo-routes.ts +62 -24
- package/src/server/management/config-routes.ts +464 -51
- package/src/server/management/context.ts +80 -2
- package/src/server/management/integration-routes.ts +498 -0
- package/src/server/management/lab-automation-routes.ts +206 -0
- package/src/server/management/lab-routes.ts +563 -0
- package/src/server/management/logs-usage-routes.ts +101 -32
- package/src/server/management/model-routes.ts +189 -131
- package/src/server/management/model-rows.ts +163 -0
- package/src/server/management/native-integration-routes.ts +769 -0
- package/src/server/management/oauth-account-routes.ts +80 -4
- package/src/server/management/provider-capability-config.ts +48 -0
- package/src/server/management/provider-routes.ts +764 -157
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +380 -0
- package/src/server/management/shared.ts +27 -11
- package/src/server/management/sidebar-routes.ts +47 -31
- package/src/server/management/storage-log-guard-routes.ts +186 -0
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +288 -32
- package/src/server/management/system-routes.ts +77 -0
- package/src/server/management/usage-summary-cache.ts +9 -1
- package/src/server/management/vision-sidecar-options.ts +167 -0
- package/src/server/management/web-search-sidecar-options.ts +120 -0
- package/src/server/management-api.ts +115 -14
- package/src/server/management-auth.ts +220 -5
- package/src/server/passive-route-linker.ts +66 -0
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +132 -5
- package/src/server/readiness.ts +99 -0
- package/src/server/relay-eager.ts +82 -42
- package/src/server/relay.ts +236 -76
- package/src/server/request-decompress.ts +113 -6
- package/src/server/request-log.ts +235 -22
- package/src/server/responses/agent-task-recovery-cache.ts +143 -0
- package/src/server/responses/agent-task-recovery.ts +465 -0
- package/src/server/responses/collaboration.ts +204 -35
- package/src/server/responses/compact.ts +442 -55
- package/src/server/responses/core.ts +2872 -331
- package/src/server/responses/empty-completion-guard.ts +276 -0
- package/src/server/responses/encrypted-payload.ts +62 -39
- package/src/server/responses/fetch-helpers.ts +79 -4
- package/src/server/responses/input-admission.ts +185 -0
- package/src/server/responses/pacing-overload.ts +13 -0
- package/src/server/responses/policy-fallback.ts +178 -0
- package/src/server/responses/responses-field-backfill.ts +251 -0
- package/src/server/responses/terminal-guard.ts +26 -5
- package/src/server/responses/upstream-error.ts +5 -0
- package/src/server/responses/ws-upstream.ts +308 -0
- package/src/server/responses-custom-tool-repair.ts +282 -0
- package/src/server/responses-item-id-repair.ts +54 -6
- package/src/server/responses-json-events.ts +90 -0
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/responses-terminal-repair.ts +342 -0
- package/src/server/responses-tool-search-repair.ts +267 -0
- package/src/server/responses-undeclared-tool-guard.ts +153 -0
- package/src/server/responses.ts +18 -2
- package/src/server/search.ts +78 -13
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +110 -22
- package/src/server/startup-action-control.ts +8 -1
- package/src/server/startup-health-cache.ts +19 -1
- package/src/server/system-env.ts +80 -9
- package/src/server/ws-bridge.ts +39 -38
- package/src/service-manager-probe.ts +892 -0
- package/src/service.ts +1111 -90
- package/src/sidecar/auth.ts +92 -0
- package/src/sidecar/candidates.ts +83 -0
- package/src/storage/cleanup.ts +2 -2
- package/src/storage/scanner.ts +1 -1
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +83 -9
- package/src/tray/windows.ts +43 -16
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +845 -0
- package/src/types/provider.ts +545 -0
- package/src/types/request.ts +384 -0
- package/src/types/tools.ts +131 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +104 -1236
- package/src/update/index.ts +32 -19
- package/src/update/job.ts +442 -67
- package/src/update/notify.ts +12 -6
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/update/transactional-install.d.mts +22 -0
- package/src/update/transactional-install.mjs +259 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +268 -16
- package/src/usage/log.ts +606 -41
- package/src/usage/summary.ts +177 -9
- package/src/usage/user-cost-overlay-reconciler.ts +313 -0
- package/src/usage/user-cost-overlays.ts +314 -0
- package/src/vision/anthropic-describe.ts +10 -6
- package/src/vision/backends.ts +97 -0
- package/src/vision/describe.ts +9 -3
- package/src/vision/eligibility.ts +250 -0
- package/src/vision/index.ts +238 -24
- package/src/vision/reasoning.ts +55 -0
- package/src/vision/routed-describe.ts +175 -0
- package/src/vision/timeout-bounds.ts +9 -0
- package/src/web-search/anthropic-executor.ts +13 -7
- package/src/web-search/backends.ts +108 -0
- package/src/web-search/exa-executor.ts +88 -0
- package/src/web-search/executor.ts +11 -3
- package/src/web-search/gemini-executor.ts +141 -0
- package/src/web-search/index.ts +150 -15
- package/src/web-search/loop.ts +279 -50
- package/src/web-search/parse.ts +125 -30
- package/src/web-search/sources.ts +60 -0
- package/src/web-search/xai-executor.ts +219 -0
- package/gui/dist/assets/index-DTpMHS4F.js +0 -67
- package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
- package/gui/dist/provider-icons/antigravity.svg +0 -1
- package/gui/dist/provider-icons/claude.svg +0 -1
- package/gui/dist/provider-icons/copilot.svg +0 -1
- package/gui/dist/provider-icons/cursor.svg +0 -2
- package/gui/dist/provider-icons/gemini.svg +0 -1
- package/gui/dist/provider-icons/grok-color.svg +0 -1
- package/gui/dist/provider-icons/kiro.svg +0 -14
- package/src/cli/internal-dispatch.ts +0 -20
package/src/providers/quota.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
effectiveCodexAuthAccountId,
|
|
4
|
+
fetchMainAccountInfoSnapshot,
|
|
5
|
+
listCodexAuthAccountsSnapshot,
|
|
6
|
+
} from "../codex/auth-api";
|
|
7
|
+
import { isMainAccountIdentityGenerationLive } from "../codex/main-account-cache";
|
|
2
8
|
import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
|
|
9
|
+
import { codexPlanKey } from "../codex/plan";
|
|
3
10
|
import { resolveEnvValue } from "../config";
|
|
4
11
|
import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth";
|
|
5
12
|
import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store";
|
|
6
13
|
import { antigravityUserAgent } from "../adapters/client-fingerprint";
|
|
7
|
-
import {
|
|
14
|
+
import { apiKeyPoolEntryId } from "./api-keys";
|
|
15
|
+
import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport";
|
|
16
|
+
import { getProviderRegistryEntry, providerCodexAccountMode, registryEntryForProviderDestination } from "./registry";
|
|
8
17
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
9
18
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers";
|
|
10
19
|
import {
|
|
@@ -12,16 +21,57 @@ import {
|
|
|
12
21
|
sweepExpiredOnWrite,
|
|
13
22
|
type GenerationContext,
|
|
14
23
|
} from "../lib/state-store-sweeper";
|
|
24
|
+
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
25
|
+
import {
|
|
26
|
+
aggregateCodexPoolCapacity,
|
|
27
|
+
CODEX_CAPACITY_MAX_QUOTA_AGE_MS,
|
|
28
|
+
type CodexCapacityAggregation,
|
|
29
|
+
type CodexCapacityQuota,
|
|
30
|
+
} from "./codex-capacity";
|
|
15
31
|
|
|
16
32
|
/** Match oauth/index REFRESH_SKEW_MS — use stored access without refresh when still fresh. */
|
|
17
33
|
const ACCOUNT_TOKEN_SKEW_MS = 60_000;
|
|
18
34
|
|
|
19
35
|
const CACHE_TTL_MS = 5 * 60_000;
|
|
20
36
|
const REQUEST_TIMEOUT_MS = 8_000;
|
|
37
|
+
/** Successful provider quota payloads are small; reject oversized or stalled JSON before parsing. */
|
|
38
|
+
export const QUOTA_RESPONSE_MAX_BYTES = 512 * 1024;
|
|
21
39
|
const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
|
|
22
40
|
const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`;
|
|
41
|
+
const COMMAND_CODE_BASE_URL = "https://api.commandcode.ai";
|
|
42
|
+
const COMMAND_CODE_WHOAMI_URL = `${COMMAND_CODE_BASE_URL}/alpha/whoami`;
|
|
43
|
+
const COMMAND_CODE_CREDITS_URL = `${COMMAND_CODE_BASE_URL}/alpha/billing/credits`;
|
|
44
|
+
const COMMAND_CODE_SUBSCRIPTIONS_URL = `${COMMAND_CODE_BASE_URL}/alpha/billing/subscriptions`;
|
|
45
|
+
const COMMAND_CODE_USAGE_URL = `${COMMAND_CODE_BASE_URL}/alpha/usage/summary`;
|
|
46
|
+
const A6API_BASE_URL = "https://api.a6api.com";
|
|
47
|
+
const OPENCODE_GO_BASE_URL = "https://opencode.ai/zen/go/v1";
|
|
48
|
+
const OPENCODE_GO_USAGE_URL = `${OPENCODE_GO_BASE_URL}/usage`;
|
|
49
|
+
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
|
50
|
+
const DEEPSEEK_BASE_URL = "https://api.deepseek.com";
|
|
51
|
+
const CLINE_BASE_URL = "https://api.cline.bot";
|
|
52
|
+
const ZAI_BASE_URL = "https://api.z.ai";
|
|
53
|
+
const ZAI_CN_BASE_URL = "https://open.bigmodel.cn";
|
|
54
|
+
const MINIMAX_REMAINS_URL = "https://www.minimax.io/v1/token_plan/remains";
|
|
55
|
+
const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1";
|
|
56
|
+
const VENICE_BASE_URL = "https://api.venice.ai/api/v1";
|
|
57
|
+
const SYNTHETIC_BASE_URL = "https://api.synthetic.new/v2";
|
|
58
|
+
const DEEPINFRA_BASE_URL = "https://api.deepinfra.com";
|
|
59
|
+
const NEURALWATT_BASE_URL = "https://api.neuralwatt.com/v1";
|
|
60
|
+
const XAI_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing";
|
|
61
|
+
const XAI_CREDITS_URL = `${XAI_BILLING_URL}?format=credits`;
|
|
23
62
|
/** Keep a failed probe's previous row at most this long before dropping it. */
|
|
24
|
-
const LAST_GOOD_MAX_AGE_MS =
|
|
63
|
+
const LAST_GOOD_MAX_AGE_MS = CODEX_CAPACITY_MAX_QUOTA_AGE_MS;
|
|
64
|
+
const nativeMainReportGenerations = new WeakMap<ProviderQuotaReport, number>();
|
|
65
|
+
let providerQuotaBeforePublishForTests: (() => void | Promise<void>) | null = null;
|
|
66
|
+
|
|
67
|
+
/** Test-only seam for identity/config invalidation after probes but before publication. */
|
|
68
|
+
export function setProviderQuotaBeforePublishForTests(
|
|
69
|
+
hook: (() => void | Promise<void>) | null,
|
|
70
|
+
): void {
|
|
71
|
+
providerQuotaBeforePublishForTests = hook;
|
|
72
|
+
}
|
|
73
|
+
const TERMINAL_QUOTA_FAILURE = Symbol("terminal-quota-failure");
|
|
74
|
+
type ProviderQuotaProbeResult = ProviderQuotaReport | null | typeof TERMINAL_QUOTA_FAILURE;
|
|
25
75
|
|
|
26
76
|
export interface ProviderQuotaWindow {
|
|
27
77
|
label: string;
|
|
@@ -29,6 +79,15 @@ export interface ProviderQuotaWindow {
|
|
|
29
79
|
resetAt?: number;
|
|
30
80
|
}
|
|
31
81
|
|
|
82
|
+
export interface ProviderQuotaCreditsUsd {
|
|
83
|
+
used: number;
|
|
84
|
+
limit: number;
|
|
85
|
+
remaining: number;
|
|
86
|
+
percent: number;
|
|
87
|
+
expiresAt?: number;
|
|
88
|
+
unlimited?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
32
91
|
export interface ProviderQuota {
|
|
33
92
|
fiveHourPercent?: number;
|
|
34
93
|
fiveHourResetAt?: number;
|
|
@@ -37,6 +96,7 @@ export interface ProviderQuota {
|
|
|
37
96
|
monthlyPercent?: number;
|
|
38
97
|
monthlyResetAt?: number;
|
|
39
98
|
customWindows?: ProviderQuotaWindow[];
|
|
99
|
+
creditsUsd?: ProviderQuotaCreditsUsd;
|
|
40
100
|
updatedAt: number;
|
|
41
101
|
}
|
|
42
102
|
|
|
@@ -47,6 +107,7 @@ export interface ProviderQuotaReport {
|
|
|
47
107
|
quota: ProviderQuota;
|
|
48
108
|
updatedAt: number;
|
|
49
109
|
reverseEngineered?: boolean;
|
|
110
|
+
aggregation?: CodexCapacityAggregation;
|
|
50
111
|
}
|
|
51
112
|
|
|
52
113
|
export interface ProviderQuotaResponse {
|
|
@@ -67,10 +128,99 @@ export function clearProviderQuotaCache(): void {
|
|
|
67
128
|
|
|
68
129
|
function cacheKey(config: OcxConfig): string {
|
|
69
130
|
const providers = Object.entries(config.providers)
|
|
70
|
-
.map(([name, provider]) =>
|
|
131
|
+
.map(([name, provider]) => {
|
|
132
|
+
const resolvedKey = typeof provider.apiKey === "string"
|
|
133
|
+
? resolveEnvValue(provider.apiKey)?.trim()
|
|
134
|
+
: undefined;
|
|
135
|
+
const activeKeyId = resolvedKey ? apiKeyPoolEntryId(resolvedKey) : "none";
|
|
136
|
+
return `${name}:${provider.adapter}:${provider.authMode ?? "key"}:${providerCodexAccountMode(name, provider) ?? "none"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}:${activeKeyId}`;
|
|
137
|
+
})
|
|
71
138
|
.sort()
|
|
72
139
|
.join("|");
|
|
73
|
-
return `${config.defaultProvider}|${
|
|
140
|
+
return `${config.defaultProvider}|${providers}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type CodexAuthAccountsSnapshotPromise = ReturnType<typeof listCodexAuthAccountsSnapshot>;
|
|
144
|
+
|
|
145
|
+
function hasCodexPoolProvider(config: OcxConfig): boolean {
|
|
146
|
+
return Object.entries(config.providers).some(([name, provider]) => (
|
|
147
|
+
provider.disabled !== true
|
|
148
|
+
&& isBuiltInChatGptForwardProvider(name, provider)
|
|
149
|
+
&& providerCodexAccountMode(name, provider) !== "direct"
|
|
150
|
+
));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function quotaSignatureValue(quota: CodexCapacityQuota | null): unknown {
|
|
154
|
+
if (!quota) return null;
|
|
155
|
+
return {
|
|
156
|
+
fiveHourPercent: quota.fiveHourPercent,
|
|
157
|
+
fiveHourResetAt: quota.fiveHourResetAt,
|
|
158
|
+
weeklyPercent: quota.weeklyPercent,
|
|
159
|
+
weeklyResetAt: quota.weeklyResetAt,
|
|
160
|
+
monthlyPercent: quota.monthlyPercent,
|
|
161
|
+
monthlyResetAt: quota.monthlyResetAt,
|
|
162
|
+
updatedAt: quota.updatedAt,
|
|
163
|
+
customWindows: [...(quota.customWindows ?? [])]
|
|
164
|
+
.map(window => ({ label: window.label, percent: window.percent, resetAt: window.resetAt }))
|
|
165
|
+
.sort((a, b) => a.label.localeCompare(b.label)),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Hash only presentation-relevant state; account ids and email addresses never enter the key. */
|
|
170
|
+
function cacheKeyWithAggregationState(
|
|
171
|
+
config: OcxConfig,
|
|
172
|
+
prefetchedSnapshot?: CodexAuthAccountsSnapshotPromise,
|
|
173
|
+
): string | Promise<string> {
|
|
174
|
+
const base = cacheKey(config);
|
|
175
|
+
if (!hasCodexPoolProvider(config)) return base;
|
|
176
|
+
return (async () => {
|
|
177
|
+
try {
|
|
178
|
+
const activeId = effectiveCodexAuthAccountId(config);
|
|
179
|
+
const snapshot = await (prefetchedSnapshot ?? listCodexAuthAccountsSnapshot(config, false));
|
|
180
|
+
const rows = snapshot.accounts.map(account => ({
|
|
181
|
+
isMain: account.isMain,
|
|
182
|
+
active: account.id === activeId,
|
|
183
|
+
plan: codexPlanKey(account.plan) ?? null,
|
|
184
|
+
paused: account.paused,
|
|
185
|
+
needsReauth: account.needsReauth === true,
|
|
186
|
+
quota: quotaSignatureValue(account.quota as CodexCapacityQuota | null),
|
|
187
|
+
}));
|
|
188
|
+
const canonicalRows = rows.map(row => JSON.stringify(row)).sort();
|
|
189
|
+
const digest = createHash("sha256").update(JSON.stringify(canonicalRows)).digest("hex").slice(0, 24);
|
|
190
|
+
return `${base}|codex-pool:${digest}`;
|
|
191
|
+
} catch {
|
|
192
|
+
return `${base}|codex-pool:unavailable`;
|
|
193
|
+
}
|
|
194
|
+
})();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function publicCapacityWindow(window: import("./codex-capacity").CodexCapacityWindowAggregation) {
|
|
198
|
+
const { totalWeight: _totalWeight, consumedWeight: _consumedWeight, remainingWeight: _remainingWeight, ...safe } = window;
|
|
199
|
+
return safe;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Management API metadata intentionally omits configured/weighted unit counts. */
|
|
203
|
+
function publicCapacityAggregation(
|
|
204
|
+
aggregation: CodexCapacityAggregation,
|
|
205
|
+
presentation: NonNullable<CodexCapacityAggregation["presentation"]>,
|
|
206
|
+
): CodexCapacityAggregation {
|
|
207
|
+
const safeCurrentAccount = presentation === "coverage-only" && aggregation.currentAccount
|
|
208
|
+
? { ...aggregation.currentAccount, quota: null }
|
|
209
|
+
: aggregation.currentAccount;
|
|
210
|
+
return {
|
|
211
|
+
...aggregation,
|
|
212
|
+
presentation,
|
|
213
|
+
...(safeCurrentAccount ? { currentAccount: safeCurrentAccount } : {}),
|
|
214
|
+
...(aggregation.fiveHour ? { fiveHour: publicCapacityWindow(aggregation.fiveHour) } : {}),
|
|
215
|
+
...(aggregation.weekly ? { weekly: publicCapacityWindow(aggregation.weekly) } : {}),
|
|
216
|
+
...(aggregation.monthly ? { monthly: publicCapacityWindow(aggregation.monthly) } : {}),
|
|
217
|
+
...(aggregation.customWindows ? {
|
|
218
|
+
customWindows: aggregation.customWindows.map(window => ({
|
|
219
|
+
label: window.label,
|
|
220
|
+
...publicCapacityWindow(window),
|
|
221
|
+
})),
|
|
222
|
+
} : {}),
|
|
223
|
+
};
|
|
74
224
|
}
|
|
75
225
|
|
|
76
226
|
function hasQuotaRows(quota: ProviderQuota | null | undefined): quota is ProviderQuota {
|
|
@@ -78,6 +228,8 @@ function hasQuotaRows(quota: ProviderQuota | null | undefined): quota is Provide
|
|
|
78
228
|
return typeof quota.fiveHourPercent === "number"
|
|
79
229
|
|| typeof quota.weeklyPercent === "number"
|
|
80
230
|
|| typeof quota.monthlyPercent === "number"
|
|
231
|
+
|| quota.creditsUsd?.unlimited === true
|
|
232
|
+
|| typeof quota.creditsUsd?.percent === "number"
|
|
81
233
|
|| !!quota.customWindows?.some(window => typeof window.percent === "number");
|
|
82
234
|
}
|
|
83
235
|
|
|
@@ -86,21 +238,27 @@ function providerLabel(providerId: string): string {
|
|
|
86
238
|
}
|
|
87
239
|
|
|
88
240
|
function normalizeResetAt(value: unknown): number | undefined {
|
|
89
|
-
if (typeof value === "number" && Number.isFinite(value)) return value
|
|
241
|
+
if (typeof value === "number" && Number.isFinite(value)) return epochMillis(value);
|
|
90
242
|
if (typeof value === "string" && value.trim()) {
|
|
91
243
|
const trimmed = value.trim();
|
|
92
244
|
// Cursor Connect RPC returns billingCycleEnd as a unix-ms decimal string ("1771077734000").
|
|
93
245
|
// Date.parse treats that as invalid; numeric epoch strings must be handled explicitly.
|
|
94
|
-
if (
|
|
246
|
+
if (/^[+-]?\d+(\.\d+)?$/.test(trimmed)) {
|
|
95
247
|
const numeric = Number(trimmed);
|
|
96
|
-
|
|
248
|
+
return epochMillis(numeric);
|
|
97
249
|
}
|
|
98
250
|
const parsed = Date.parse(trimmed);
|
|
99
|
-
return Number.isFinite(parsed) ? parsed : undefined;
|
|
251
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
100
252
|
}
|
|
101
253
|
return undefined;
|
|
102
254
|
}
|
|
103
255
|
|
|
256
|
+
/** Unix 0 / negative values are sentinels, not reset clocks (Command Code fiveHour.resetAt: 0). */
|
|
257
|
+
function epochMillis(value: number): number | undefined {
|
|
258
|
+
if (!Number.isFinite(value) || value <= 0) return undefined;
|
|
259
|
+
return value > 10_000_000_000 ? value : value * 1000;
|
|
260
|
+
}
|
|
261
|
+
|
|
104
262
|
function toFiniteNumber(value: unknown): number | undefined {
|
|
105
263
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
106
264
|
if (typeof value === "string" && value.trim()) {
|
|
@@ -119,11 +277,799 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|
|
119
277
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
120
278
|
}
|
|
121
279
|
|
|
280
|
+
const QUOTA_JSON_READ_FAILURE = Symbol("quota-json-read-failure");
|
|
281
|
+
|
|
282
|
+
async function readQuotaJson(
|
|
283
|
+
response: Response,
|
|
284
|
+
timeoutMs = REQUEST_TIMEOUT_MS,
|
|
285
|
+
): Promise<unknown | typeof QUOTA_JSON_READ_FAILURE> {
|
|
286
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
287
|
+
if (Number.isFinite(declaredLength) && declaredLength > QUOTA_RESPONSE_MAX_BYTES) {
|
|
288
|
+
try {
|
|
289
|
+
void response.body?.cancel(
|
|
290
|
+
new DOMException("Provider quota response is too large", "QuotaExceededError"),
|
|
291
|
+
).catch(() => undefined);
|
|
292
|
+
} catch {
|
|
293
|
+
// Best-effort cancellation only.
|
|
294
|
+
}
|
|
295
|
+
return QUOTA_JSON_READ_FAILURE;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const bounded = await readBoundedResponseBody(response, {
|
|
300
|
+
maxBytes: QUOTA_RESPONSE_MAX_BYTES,
|
|
301
|
+
totalTimeoutMs: timeoutMs,
|
|
302
|
+
inactivityTimeoutMs: timeoutMs,
|
|
303
|
+
});
|
|
304
|
+
if (bounded.oversized || bounded.truncated || !bounded.displaySafe) return QUOTA_JSON_READ_FAILURE;
|
|
305
|
+
return JSON.parse(bounded.text) as unknown;
|
|
306
|
+
} catch {
|
|
307
|
+
return QUOTA_JSON_READ_FAILURE;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Test-only access to the quota reader's deadline and cancellation contract. */
|
|
312
|
+
export async function readProviderQuotaJsonForTests(response: Response, timeoutMs: number): Promise<unknown> {
|
|
313
|
+
const result = await readQuotaJson(response, timeoutMs);
|
|
314
|
+
return result === QUOTA_JSON_READ_FAILURE ? null : result;
|
|
315
|
+
}
|
|
316
|
+
|
|
122
317
|
function isBuiltInChatGptForwardProvider(name: string, provider: OcxProviderConfig): boolean {
|
|
123
318
|
return name === OPENAI_CODEX_PROVIDER_ID && isCanonicalOpenAiForwardProvider(provider);
|
|
124
319
|
}
|
|
125
320
|
|
|
126
|
-
function
|
|
321
|
+
function isCanonicalA6apiBaseUrl(baseUrl: string): boolean {
|
|
322
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
323
|
+
return normalized === A6API_BASE_URL || normalized === `${A6API_BASE_URL}/v1`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function isCanonicalOpenCodeGoBaseUrl(baseUrl: string): boolean {
|
|
327
|
+
return normalizedBaseUrl(baseUrl) === OPENCODE_GO_BASE_URL;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function isCanonicalOpenRouterBaseUrl(baseUrl: string): boolean {
|
|
331
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
332
|
+
return normalized === OPENROUTER_BASE_URL;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function isCanonicalDeepSeekBaseUrl(baseUrl: string): boolean {
|
|
336
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
337
|
+
return normalized === DEEPSEEK_BASE_URL || normalized === `${DEEPSEEK_BASE_URL}/v1`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function isCanonicalClineBaseUrl(baseUrl: string): boolean {
|
|
341
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
342
|
+
return normalized === CLINE_BASE_URL || normalized === `${CLINE_BASE_URL}/api/v1`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function isCanonicalZaiBaseUrl(baseUrl: string): boolean {
|
|
346
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
347
|
+
return normalized === ZAI_BASE_URL
|
|
348
|
+
|| normalized === `${ZAI_BASE_URL}/api/coding/paas/v4`
|
|
349
|
+
|| normalized === ZAI_CN_BASE_URL
|
|
350
|
+
|| normalized === `${ZAI_CN_BASE_URL}/api/coding/paas/v4`
|
|
351
|
+
// BigModel serves the same GLM Coding Plan on the OpenAI Responses wire at /api/v1.
|
|
352
|
+
|| normalized === `${ZAI_CN_BASE_URL}/api/v1`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function isCanonicalMinimaxBaseUrl(baseUrl: string): boolean {
|
|
356
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
357
|
+
return normalized === "https://api.minimax.io/v1" || normalized === "https://api.minimaxi.com/v1";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function isCanonicalMoonshotBaseUrl(baseUrl: string): boolean {
|
|
361
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
362
|
+
return normalized === MOONSHOT_BASE_URL || normalized === "https://api.moonshot.cn/v1";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function isCanonicalVeniceBaseUrl(baseUrl: string): boolean {
|
|
366
|
+
return normalizedBaseUrl(baseUrl) === VENICE_BASE_URL;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function isCanonicalSyntheticBaseUrl(baseUrl: string): boolean {
|
|
370
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
371
|
+
return normalized === SYNTHETIC_BASE_URL || normalized === "https://api.synthetic.new/openai/v1";
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function isCanonicalDeepInfraBaseUrl(baseUrl: string): boolean {
|
|
375
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
376
|
+
return normalized === DEEPINFRA_BASE_URL || normalized === `${DEEPINFRA_BASE_URL}/v1/openai`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function isCanonicalNeuralwattBaseUrl(baseUrl: string): boolean {
|
|
380
|
+
return normalizedBaseUrl(baseUrl) === NEURALWATT_BASE_URL;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function a6apiPayload(value: unknown): Record<string, unknown> | null {
|
|
384
|
+
const body = asRecord(value);
|
|
385
|
+
return asRecord(body?.data) ?? body;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function firstFinite(record: Record<string, unknown> | null, names: string[]): number | undefined {
|
|
389
|
+
if (!record) return undefined;
|
|
390
|
+
for (const name of names) {
|
|
391
|
+
const value = toFiniteNumber(record[name]);
|
|
392
|
+
if (value !== undefined) return value;
|
|
393
|
+
}
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
398
|
+
// Never send a configured API key to a lookalike host or through a redirect.
|
|
399
|
+
if (!isCanonicalA6apiBaseUrl(config.baseUrl)) return null;
|
|
400
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
401
|
+
if (!apiKey) return null;
|
|
402
|
+
const headers = { Accept: "application/json", Authorization: `Bearer ${apiKey}` } as const;
|
|
403
|
+
const [subscriptionResponse, tokenResponse] = await Promise.all([
|
|
404
|
+
fetch(`${A6API_BASE_URL}/dashboard/billing/subscription`, {
|
|
405
|
+
headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
406
|
+
}),
|
|
407
|
+
fetch(`${A6API_BASE_URL}/api/usage/token/`, {
|
|
408
|
+
headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
409
|
+
}),
|
|
410
|
+
]);
|
|
411
|
+
if (!subscriptionResponse.ok || !tokenResponse.ok) {
|
|
412
|
+
const statuses = [subscriptionResponse.status, tokenResponse.status];
|
|
413
|
+
// 408/429 are transient (timeout/throttle), not invalid-account signals: keep the
|
|
414
|
+
// last-good row like 5xx/network failures. 401/403 (bad key) and 404 (contract change)
|
|
415
|
+
// stay terminal.
|
|
416
|
+
return statuses.some(status => status >= 400 && status < 500 && status !== 429 && status !== 408)
|
|
417
|
+
? TERMINAL_QUOTA_FAILURE
|
|
418
|
+
: null;
|
|
419
|
+
}
|
|
420
|
+
const [subscriptionBody, tokenBody] = await Promise.all([
|
|
421
|
+
readQuotaJson(subscriptionResponse),
|
|
422
|
+
readQuotaJson(tokenResponse),
|
|
423
|
+
]);
|
|
424
|
+
if (subscriptionBody === QUOTA_JSON_READ_FAILURE || tokenBody === QUOTA_JSON_READ_FAILURE) return null;
|
|
425
|
+
const subscription = a6apiPayload(subscriptionBody);
|
|
426
|
+
const token = a6apiPayload(tokenBody);
|
|
427
|
+
const unlimited = token?.unlimited_quota === true
|
|
428
|
+
|| token?.unlimited_quota === 1
|
|
429
|
+
|| token?.unlimited_quota === "true";
|
|
430
|
+
const normalizedExpiry = normalizeResetAt(token?.expires_at);
|
|
431
|
+
const expiry = normalizedExpiry && normalizedExpiry > 0
|
|
432
|
+
? { expiresAt: normalizedExpiry }
|
|
433
|
+
: {};
|
|
434
|
+
if (unlimited) {
|
|
435
|
+
return report(provider, "a6api:billing", {
|
|
436
|
+
creditsUsd: {
|
|
437
|
+
used: 0,
|
|
438
|
+
limit: 0,
|
|
439
|
+
remaining: 0,
|
|
440
|
+
percent: 0,
|
|
441
|
+
unlimited: true,
|
|
442
|
+
...expiry,
|
|
443
|
+
},
|
|
444
|
+
customWindows: [{ label: "Unlimited API credits", percent: 0 }],
|
|
445
|
+
updatedAt: Date.now(),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
const limitUsd = firstFinite(subscription, ["hard_limit_usd"]);
|
|
449
|
+
const grantedUnits = firstFinite(token, ["total_granted"]);
|
|
450
|
+
const usedUnits = firstFinite(token, ["total_used"]);
|
|
451
|
+
const availableUnits = firstFinite(token, ["total_available"]);
|
|
452
|
+
const reconciledUnits = usedUnits !== undefined && availableUnits !== undefined
|
|
453
|
+
? usedUnits + availableUnits
|
|
454
|
+
: undefined;
|
|
455
|
+
const reconciliationTolerance = grantedUnits !== undefined
|
|
456
|
+
? Math.abs(grantedUnits) * 1e-9
|
|
457
|
+
: 0;
|
|
458
|
+
if (limitUsd === undefined || grantedUnits === undefined || usedUnits === undefined
|
|
459
|
+
|| availableUnits === undefined || limitUsd <= 0 || grantedUnits <= 0
|
|
460
|
+
|| usedUnits < 0 || availableUnits < 0
|
|
461
|
+
|| reconciledUnits === undefined
|
|
462
|
+
|| Math.abs(reconciledUnits - grantedUnits) > reconciliationTolerance) return TERMINAL_QUOTA_FAILURE;
|
|
463
|
+
const usdPerUnit = limitUsd / grantedUnits;
|
|
464
|
+
const usedUsd = usedUnits * usdPerUnit;
|
|
465
|
+
const remainingUsd = Math.max(0, availableUnits * usdPerUnit);
|
|
466
|
+
const percent = normalizePercent((usedUsd / limitUsd) * 100);
|
|
467
|
+
if (percent === undefined) return TERMINAL_QUOTA_FAILURE;
|
|
468
|
+
const label = `API credits ($${remainingUsd.toFixed(2)} of $${limitUsd.toFixed(2)} remaining)`;
|
|
469
|
+
return report(provider, "a6api:billing", {
|
|
470
|
+
creditsUsd: {
|
|
471
|
+
used: usedUsd,
|
|
472
|
+
limit: limitUsd,
|
|
473
|
+
remaining: remainingUsd,
|
|
474
|
+
percent,
|
|
475
|
+
...expiry,
|
|
476
|
+
},
|
|
477
|
+
customWindows: [{ label, percent }],
|
|
478
|
+
updatedAt: Date.now(),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function parseOpenCodeGoUsageWindow(value: unknown): { percent: number; resetAt?: number } | null {
|
|
483
|
+
const row = asRecord(value);
|
|
484
|
+
if (!row) return null;
|
|
485
|
+
const percent = normalizePercent(row.percent);
|
|
486
|
+
if (percent === undefined) return null;
|
|
487
|
+
const resetAt = normalizeResetAt(row.resetsAt);
|
|
488
|
+
return { percent, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function fetchOpenCodeGoQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
492
|
+
// Never send a configured API key when the provider destination is not the built-in Go endpoint.
|
|
493
|
+
if (!isCanonicalOpenCodeGoBaseUrl(config.baseUrl)) return null;
|
|
494
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
495
|
+
if (!apiKey) return null;
|
|
496
|
+
const response = await fetch(OPENCODE_GO_USAGE_URL, {
|
|
497
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
498
|
+
redirect: "error",
|
|
499
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
500
|
+
});
|
|
501
|
+
if (!response.ok) {
|
|
502
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
503
|
+
? TERMINAL_QUOTA_FAILURE
|
|
504
|
+
: null;
|
|
505
|
+
}
|
|
506
|
+
const body = asRecord(await readQuotaJson(response));
|
|
507
|
+
const usage = asRecord(body?.usage);
|
|
508
|
+
if (!usage) return null;
|
|
509
|
+
const rolling = parseOpenCodeGoUsageWindow(usage.rolling);
|
|
510
|
+
const weekly = parseOpenCodeGoUsageWindow(usage.weekly);
|
|
511
|
+
const monthly = parseOpenCodeGoUsageWindow(usage.monthly);
|
|
512
|
+
const quota: ProviderQuota = {
|
|
513
|
+
...(rolling ? {
|
|
514
|
+
fiveHourPercent: rolling.percent,
|
|
515
|
+
...(rolling.resetAt !== undefined ? { fiveHourResetAt: rolling.resetAt } : {}),
|
|
516
|
+
} : {}),
|
|
517
|
+
...(weekly ? {
|
|
518
|
+
weeklyPercent: weekly.percent,
|
|
519
|
+
...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}),
|
|
520
|
+
} : {}),
|
|
521
|
+
...(monthly ? {
|
|
522
|
+
monthlyPercent: monthly.percent,
|
|
523
|
+
...(monthly.resetAt !== undefined ? { monthlyResetAt: monthly.resetAt } : {}),
|
|
524
|
+
} : {}),
|
|
525
|
+
updatedAt: Date.now(),
|
|
526
|
+
};
|
|
527
|
+
return report(provider, "opencode-go:usage", quota);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* OpenRouter `GET /api/v1/key` — the key's own credit balance and optional
|
|
532
|
+
* per-key spending cap. `limit` is the configured cap (absent = uncapped);
|
|
533
|
+
* `usage` is lifetime spend; `limit_remaining` is what is left of the cap.
|
|
534
|
+
* When no cap is set there is no hard limit to meter against, so no bar is
|
|
535
|
+
* produced — the provider falls back to its documented reference.
|
|
536
|
+
*/
|
|
537
|
+
async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
538
|
+
// Never send a configured API key to a lookalike host or through a redirect.
|
|
539
|
+
if (!isCanonicalOpenRouterBaseUrl(config.baseUrl)) return null;
|
|
540
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
541
|
+
if (!apiKey) return null;
|
|
542
|
+
const response = await fetch(`${OPENROUTER_BASE_URL}/key`, {
|
|
543
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
544
|
+
redirect: "error",
|
|
545
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
546
|
+
});
|
|
547
|
+
if (!response.ok) {
|
|
548
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
549
|
+
? TERMINAL_QUOTA_FAILURE
|
|
550
|
+
: null;
|
|
551
|
+
}
|
|
552
|
+
const body = asRecord(await readQuotaJson(response));
|
|
553
|
+
const data = asRecord(body?.data) ?? body;
|
|
554
|
+
if (!data) return null;
|
|
555
|
+
const limit = toFiniteNumber(data.limit);
|
|
556
|
+
const limitRemaining = toFiniteNumber(data.limit_remaining);
|
|
557
|
+
const usage = toFiniteNumber(data.usage);
|
|
558
|
+
// A successful no-cap response is a DELIBERATE change, not a transient
|
|
559
|
+
// failure: the old capped row must be dropped, not preserved as last-good.
|
|
560
|
+
if (limit === undefined || limit <= 0) return TERMINAL_QUOTA_FAILURE;
|
|
561
|
+
// Prefer the authoritative remaining-cap value when present: `usage` is
|
|
562
|
+
// lifetime accumulated spend and overstates a reset or re-capped key.
|
|
563
|
+
const used = limitRemaining !== undefined
|
|
564
|
+
? Math.max(0, limit - limitRemaining)
|
|
565
|
+
: usage !== undefined && usage >= 0 ? usage : undefined;
|
|
566
|
+
if (used === undefined) return null;
|
|
567
|
+
const percent = normalizePercent((used / limit) * 100);
|
|
568
|
+
if (percent === undefined) return null;
|
|
569
|
+
const remaining = Math.max(0, limit - used);
|
|
570
|
+
const label = `API credits ($${remaining.toFixed(2)} of $${limit.toFixed(2)} remaining)`;
|
|
571
|
+
return report(provider, "openrouter:key-info", {
|
|
572
|
+
customWindows: [{ label, percent }],
|
|
573
|
+
updatedAt: Date.now(),
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* DeepSeek `GET /user/balance` — the account's granted + topped-up credit
|
|
579
|
+
* balance. The payload places `total_balance` / `granted_balance` inside
|
|
580
|
+
* entries of `balance_infos` (one row per currency); the row for the account's
|
|
581
|
+
* currency is selected by preference. `granted_balance` is a CURRENT balance
|
|
582
|
+
* component, not the original grant ceiling, so no consumed percentage is
|
|
583
|
+
* fabricated — the balance is reported as a balance-only window.
|
|
584
|
+
*/
|
|
585
|
+
async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
586
|
+
if (!isCanonicalDeepSeekBaseUrl(config.baseUrl)) return null;
|
|
587
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
588
|
+
if (!apiKey) return null;
|
|
589
|
+
const response = await fetch(`${DEEPSEEK_BASE_URL}/user/balance`, {
|
|
590
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
591
|
+
redirect: "error",
|
|
592
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
593
|
+
});
|
|
594
|
+
if (!response.ok) {
|
|
595
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
596
|
+
? TERMINAL_QUOTA_FAILURE
|
|
597
|
+
: null;
|
|
598
|
+
}
|
|
599
|
+
const body = asRecord(await readQuotaJson(response));
|
|
600
|
+
// The payload nests balances under `balance_infos` rows keyed by currency;
|
|
601
|
+
// prefer a USD row, then CNY, then the first row that parses.
|
|
602
|
+
const infos = Array.isArray(body?.balance_infos) ? body.balance_infos as unknown[] : null;
|
|
603
|
+
const rows = infos
|
|
604
|
+
? infos.map((raw): Record<string, unknown> | null => asRecord(raw)).filter((r): r is Record<string, unknown> => r !== null)
|
|
605
|
+
: [];
|
|
606
|
+
const pick = (currency: string): Record<string, unknown> | null =>
|
|
607
|
+
rows.find(row => String(row.currency ?? "").toUpperCase() === currency) ?? null;
|
|
608
|
+
const preferred = pick("USD") ?? pick("CNY") ?? rows[0] ?? null;
|
|
609
|
+
if (!preferred) return null;
|
|
610
|
+
const totalBalance = toFiniteNumber(preferred.total_balance);
|
|
611
|
+
const grantedBalance = toFiniteNumber(preferred.granted_balance);
|
|
612
|
+
const toppedUp = toFiniteNumber(preferred.topped_up_balance);
|
|
613
|
+
const balance = totalBalance ?? grantedBalance ?? toppedUp;
|
|
614
|
+
if (balance === undefined || balance < 0) return null;
|
|
615
|
+
const label = grantedBalance !== undefined && grantedBalance > 0
|
|
616
|
+
? `API balance ($${balance.toFixed(2)} total, $${grantedBalance.toFixed(2)} granted)`
|
|
617
|
+
: `API balance ($${balance.toFixed(2)})`;
|
|
618
|
+
return report(provider, "deepseek:balance", {
|
|
619
|
+
customWindows: [{ label, percent: 0 }],
|
|
620
|
+
updatedAt: Date.now(),
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* ClinePass `GET /api/v1/users/me/plan/usage-limits` — the subscription's
|
|
626
|
+
* rolling five-hour, weekly, and monthly utilization, matching the existing
|
|
627
|
+
* ProviderQuota windows directly. The endpoint 404s (or returns a null plan)
|
|
628
|
+
* for accounts without an active ClinePass, which is a no-report, not an error.
|
|
629
|
+
*/
|
|
630
|
+
async function fetchClineQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
631
|
+
if (!isCanonicalClineBaseUrl(config.baseUrl)) return null;
|
|
632
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
633
|
+
if (!apiKey) return null;
|
|
634
|
+
const response = await fetch(`${CLINE_BASE_URL}/api/v1/users/me/plan/usage-limits`, {
|
|
635
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
636
|
+
redirect: "error",
|
|
637
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
638
|
+
});
|
|
639
|
+
if (!response.ok) {
|
|
640
|
+
// 404 = no active plan; a plain "no plan" is a no-report, everything else
|
|
641
|
+
// 4xx (except 408/429) is a credential/contract problem.
|
|
642
|
+
if (response.status === 404) return null;
|
|
643
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
644
|
+
? TERMINAL_QUOTA_FAILURE
|
|
645
|
+
: null;
|
|
646
|
+
}
|
|
647
|
+
const body = asRecord(await readQuotaJson(response));
|
|
648
|
+
const data = asRecord(body?.data) ?? body;
|
|
649
|
+
const limits = Array.isArray(data?.limits) ? data.limits : null;
|
|
650
|
+
if (!limits) return null;
|
|
651
|
+
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
652
|
+
let windows = 0;
|
|
653
|
+
for (const raw of limits) {
|
|
654
|
+
const row = asRecord(raw);
|
|
655
|
+
if (!row) continue;
|
|
656
|
+
const percent = normalizePercent(row.percentUsed);
|
|
657
|
+
if (percent === undefined) continue;
|
|
658
|
+
const resetAt = normalizeResetAt(row.resetsAt);
|
|
659
|
+
if (row.type === "five_hour") {
|
|
660
|
+
quota.fiveHourPercent = percent;
|
|
661
|
+
if (resetAt !== undefined) quota.fiveHourResetAt = resetAt;
|
|
662
|
+
windows += 1;
|
|
663
|
+
} else if (row.type === "weekly") {
|
|
664
|
+
quota.weeklyPercent = percent;
|
|
665
|
+
if (resetAt !== undefined) quota.weeklyResetAt = resetAt;
|
|
666
|
+
windows += 1;
|
|
667
|
+
} else if (row.type === "monthly") {
|
|
668
|
+
quota.monthlyPercent = percent;
|
|
669
|
+
if (resetAt !== undefined) quota.monthlyResetAt = resetAt;
|
|
670
|
+
windows += 1;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return windows > 0 ? report(provider, "cline:plan-usage-limits", quota) : null;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Z.AI GLM Coding Plan `GET /api/monitor/usage/quota/limit` — the coding-plan
|
|
678
|
+
* limits arrive as a `limits` array of `TOKENS_LIMIT` (newer plans call the
|
|
679
|
+
* same rows `CREDIT_LIMIT`) and `TIME_LIMIT` rows. `TOKENS_LIMIT`/`CREDIT_LIMIT`
|
|
680
|
+
* rows carry the window length as `unit`/`number`: unit 3 is hours (number 5 →
|
|
681
|
+
* the rolling five-hour window), unit 6 is weeks (number 1 → the weekly
|
|
682
|
+
* window). `TIME_LIMIT` rows are the monthly MCP tool budget (Web Search / Web
|
|
683
|
+
* Reader / Zread). Every row's `percentage` is the consumed share (falling
|
|
684
|
+
* back to `currentValue`/`usage` when absent) and `nextResetTime` (unix ms)
|
|
685
|
+
* the window reset.
|
|
686
|
+
*/
|
|
687
|
+
export function parseZaiQuotaLimits(data: Record<string, unknown> | null): ProviderQuota | null {
|
|
688
|
+
const limits = Array.isArray(data?.limits) ? data.limits as unknown[] : null;
|
|
689
|
+
if (!limits) return null;
|
|
690
|
+
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
691
|
+
let windows = 0;
|
|
692
|
+
for (const raw of limits) {
|
|
693
|
+
const row = asRecord(raw);
|
|
694
|
+
if (!row) continue;
|
|
695
|
+
const resetAt = normalizeResetAt(row.nextResetTime);
|
|
696
|
+
let percent = normalizePercent(row.percentage);
|
|
697
|
+
if (percent === undefined) {
|
|
698
|
+
const used = toFiniteNumber(row.currentValue);
|
|
699
|
+
const total = toFiniteNumber(row.usage);
|
|
700
|
+
if (used !== undefined && total !== undefined && total > 0) {
|
|
701
|
+
percent = normalizePercent((used / total) * 100);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (percent === undefined) continue;
|
|
705
|
+
if (row.type === "TOKENS_LIMIT" || row.type === "CREDIT_LIMIT") {
|
|
706
|
+
const unit = toFiniteNumber(row.unit);
|
|
707
|
+
const number = toFiniteNumber(row.number);
|
|
708
|
+
if (unit === 3 && number === 5) {
|
|
709
|
+
quota.fiveHourPercent = percent;
|
|
710
|
+
if (resetAt !== undefined) quota.fiveHourResetAt = resetAt;
|
|
711
|
+
windows += 1;
|
|
712
|
+
} else if (unit === 6 && number === 1) {
|
|
713
|
+
quota.weeklyPercent = percent;
|
|
714
|
+
if (resetAt !== undefined) quota.weeklyResetAt = resetAt;
|
|
715
|
+
windows += 1;
|
|
716
|
+
}
|
|
717
|
+
} else if (row.type === "TIME_LIMIT") {
|
|
718
|
+
quota.monthlyPercent = percent;
|
|
719
|
+
if (resetAt !== undefined) quota.monthlyResetAt = resetAt;
|
|
720
|
+
windows += 1;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return windows > 0 ? quota : null;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Legacy Z.AI payload shape: percent fields with window identifiers directly on
|
|
728
|
+
* the data object (optionally nested under `quota`). Kept as a fallback so
|
|
729
|
+
* older responses keep rendering when the `limits` array is absent.
|
|
730
|
+
*/
|
|
731
|
+
function parseZaiQuotaLegacyFields(data: Record<string, unknown> | null): ProviderQuota | null {
|
|
732
|
+
if (!data) return null;
|
|
733
|
+
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
734
|
+
let windows = 0;
|
|
735
|
+
const percentAt = (key: string): number | undefined => {
|
|
736
|
+
const value = normalizePercent(data[key]);
|
|
737
|
+
if (value !== undefined) return value;
|
|
738
|
+
const nested = asRecord(data.quota);
|
|
739
|
+
return nested ? normalizePercent(nested[key]) : undefined;
|
|
740
|
+
};
|
|
741
|
+
const fiveHour = percentAt("fiveHourPercent") ?? percentAt("fiveHourUsage") ?? percentAt("fiveHourUsed");
|
|
742
|
+
const weekly = percentAt("weeklyPercent") ?? percentAt("weeklyUsage") ?? percentAt("weeklyUsed");
|
|
743
|
+
const monthly = percentAt("monthlyPercent") ?? percentAt("mcpPercent") ?? percentAt("monthlyMCPUsage");
|
|
744
|
+
if (fiveHour !== undefined) {
|
|
745
|
+
quota.fiveHourPercent = fiveHour;
|
|
746
|
+
windows += 1;
|
|
747
|
+
}
|
|
748
|
+
if (weekly !== undefined) {
|
|
749
|
+
quota.weeklyPercent = weekly;
|
|
750
|
+
windows += 1;
|
|
751
|
+
}
|
|
752
|
+
if (monthly !== undefined) {
|
|
753
|
+
quota.monthlyPercent = monthly;
|
|
754
|
+
windows += 1;
|
|
755
|
+
}
|
|
756
|
+
return windows > 0 ? quota : null;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Fetches the Z.AI GLM Coding Plan quota — on whichever region the provider
|
|
761
|
+
* points at (api.z.ai or open.bigmodel.cn). Authenticates with the API key as
|
|
762
|
+
* a Bearer token per Z.AI's API reference. The `limits` array shape is
|
|
763
|
+
* preferred; older field-name payloads fall back to the legacy parser.
|
|
764
|
+
*/
|
|
765
|
+
async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
766
|
+
if (!isCanonicalZaiBaseUrl(config.baseUrl)) return null;
|
|
767
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
768
|
+
if (!apiKey) return null;
|
|
769
|
+
const normalized = normalizedBaseUrl(config.baseUrl);
|
|
770
|
+
const monitorHost = normalized === ZAI_BASE_URL || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4`
|
|
771
|
+
? ZAI_BASE_URL
|
|
772
|
+
: ZAI_CN_BASE_URL;
|
|
773
|
+
const response = await fetch(`${monitorHost}/api/monitor/usage/quota/limit`, {
|
|
774
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
775
|
+
redirect: "error",
|
|
776
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
777
|
+
});
|
|
778
|
+
if (!response.ok) {
|
|
779
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
780
|
+
? TERMINAL_QUOTA_FAILURE
|
|
781
|
+
: null;
|
|
782
|
+
}
|
|
783
|
+
const body = asRecord(await readQuotaJson(response));
|
|
784
|
+
if (!body || body.success === false) return null;
|
|
785
|
+
const data = asRecord(body.data) ?? body;
|
|
786
|
+
const quota = Array.isArray(data?.limits)
|
|
787
|
+
? parseZaiQuotaLimits(data)
|
|
788
|
+
: parseZaiQuotaLegacyFields(data);
|
|
789
|
+
return quota ? report(provider, "zai:quota-limit", quota) : null;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* MiniMax Token Plan `GET /v1/token_plan/remains` — the subscription's
|
|
794
|
+
* remaining quota as a countdown-time value (ms). The endpoint does not expose
|
|
795
|
+
* the plan's total duration, so no percentage is fabricated from a presumed
|
|
796
|
+
* window: the remaining time is reported as a duration-only window. When the
|
|
797
|
+
* API supplies a total (`total_time` / `plan_duration_ms`), a consumed share
|
|
798
|
+
* is derived from it. Region selects the host: `minimax` → www.minimax.io,
|
|
799
|
+
* `minimax-cn` → api.minimaxi.com.
|
|
800
|
+
*/
|
|
801
|
+
async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
802
|
+
if (!isCanonicalMinimaxBaseUrl(config.baseUrl)) return null;
|
|
803
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
804
|
+
if (!apiKey) return null;
|
|
805
|
+
const cnHost = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.minimaxi.com");
|
|
806
|
+
const remainsUrl = cnHost ? "https://api.minimaxi.com/v1/token_plan/remains" : MINIMAX_REMAINS_URL;
|
|
807
|
+
const response = await fetch(remainsUrl, {
|
|
808
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
809
|
+
redirect: "error",
|
|
810
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
811
|
+
});
|
|
812
|
+
if (!response.ok) {
|
|
813
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
814
|
+
? TERMINAL_QUOTA_FAILURE
|
|
815
|
+
: null;
|
|
816
|
+
}
|
|
817
|
+
const body = asRecord(await readQuotaJson(response));
|
|
818
|
+
if (!body || body.success === false) return null;
|
|
819
|
+
const data = asRecord(body.data) ?? body;
|
|
820
|
+
const remainsMs = toFiniteNumber(data.remains_time ?? data.remainsTime);
|
|
821
|
+
if (remainsMs === undefined || remainsMs < 0) return null;
|
|
822
|
+
const hours = Math.floor(remainsMs / 3_600_000);
|
|
823
|
+
const label = `Token Plan remaining (${hours}h)`;
|
|
824
|
+
// Only derive a consumed share when the API actually reports the plan total;
|
|
825
|
+
// a presumed window (e.g. 30 days) would fabricate utilization. A valid
|
|
826
|
+
// response that omits the total after a prior refresh had it is a DELIBERATE
|
|
827
|
+
// contract change — the old row must be dropped (terminal), not preserved as
|
|
828
|
+
// a transient last-good.
|
|
829
|
+
const totalMs = toFiniteNumber(data.total_time ?? data.plan_duration_ms ?? data.total_duration_ms);
|
|
830
|
+
if (totalMs === undefined || totalMs <= 0) return TERMINAL_QUOTA_FAILURE;
|
|
831
|
+
const consumed = Math.max(0, totalMs - remainsMs);
|
|
832
|
+
const percent = normalizePercent((consumed / totalMs) * 100);
|
|
833
|
+
if (percent === undefined) return null;
|
|
834
|
+
return report(provider, "minimax:token-plan-remains", {
|
|
835
|
+
customWindows: [{ label, percent }],
|
|
836
|
+
updatedAt: Date.now(),
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Moonshot/Kimi `GET /v1/users/me/balance` — the account's available balance
|
|
842
|
+
* (voucher + cash). Renders a single balance window against the sum of
|
|
843
|
+
* voucher + cash when positive (there is no per-window rate limit to meter).
|
|
844
|
+
*/
|
|
845
|
+
async function fetchMoonshotQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
846
|
+
if (!isCanonicalMoonshotBaseUrl(config.baseUrl)) return null;
|
|
847
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
848
|
+
if (!apiKey) return null;
|
|
849
|
+
const host = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.moonshot.cn") ? "https://api.moonshot.cn/v1" : MOONSHOT_BASE_URL;
|
|
850
|
+
const response = await fetch(`${host}/users/me/balance`, {
|
|
851
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
852
|
+
redirect: "error",
|
|
853
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
854
|
+
});
|
|
855
|
+
if (!response.ok) {
|
|
856
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
857
|
+
? TERMINAL_QUOTA_FAILURE
|
|
858
|
+
: null;
|
|
859
|
+
}
|
|
860
|
+
const body = asRecord(await readQuotaJson(response));
|
|
861
|
+
const data = asRecord(body?.data) ?? body;
|
|
862
|
+
if (!data) return null;
|
|
863
|
+
const available = toFiniteNumber(data.available_balance);
|
|
864
|
+
const voucher = toFiniteNumber(data.voucher_balance);
|
|
865
|
+
const cash = toFiniteNumber(data.cash_balance);
|
|
866
|
+
if (available === undefined || available < 0) return null;
|
|
867
|
+
// Moonshot exposes no per-window quota ceiling, only a balance — report it
|
|
868
|
+
// as a balance-only window (percent 0) rather than a fabricated utilization.
|
|
869
|
+
// Currency is host-scoped: China platform (api.moonshot.cn) bills in CNY;
|
|
870
|
+
// the international platform (api.moonshot.ai) bills in USD. Do not force
|
|
871
|
+
// either side into the other unit — the number is correct, only the unit
|
|
872
|
+
// must match the host.
|
|
873
|
+
const isChinaHost = host.startsWith("https://api.moonshot.cn");
|
|
874
|
+
const money = (n: number) => isChinaHost ? `¥${n.toFixed(2)}` : `$${n.toFixed(2)}`;
|
|
875
|
+
const unit = isChinaHost ? "CNY" : "USD";
|
|
876
|
+
const label = voucher !== undefined && cash !== undefined
|
|
877
|
+
? `Balance (${money(available)} ${unit} available, ${money(voucher)} voucher)`
|
|
878
|
+
: `Balance (${money(available)} ${unit} available)`;
|
|
879
|
+
return report(provider, "moonshot:balance", {
|
|
880
|
+
customWindows: [{ label, percent: 0 }],
|
|
881
|
+
updatedAt: Date.now(),
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Venice `GET /api/v1/billing/balance` — DIEM (native credits) or USD balance.
|
|
887
|
+
* Shows the remaining balance; epoch allocation progress when present.
|
|
888
|
+
*/
|
|
889
|
+
async function fetchVeniceQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
890
|
+
if (!isCanonicalVeniceBaseUrl(config.baseUrl)) return null;
|
|
891
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
892
|
+
if (!apiKey) return null;
|
|
893
|
+
const response = await fetch(`${VENICE_BASE_URL}/billing/balance`, {
|
|
894
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
895
|
+
redirect: "error",
|
|
896
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
897
|
+
});
|
|
898
|
+
if (!response.ok) {
|
|
899
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
900
|
+
? TERMINAL_QUOTA_FAILURE
|
|
901
|
+
: null;
|
|
902
|
+
}
|
|
903
|
+
const body = asRecord(await readQuotaJson(response));
|
|
904
|
+
const data = asRecord(body?.data) ?? body;
|
|
905
|
+
if (!data) return null;
|
|
906
|
+
const diemBalance = toFiniteNumber(data.balance);
|
|
907
|
+
const usdBalance = toFiniteNumber(data.balance_usd);
|
|
908
|
+
const epochUsed = toFiniteNumber(data.diem_epoch_used);
|
|
909
|
+
const epochAllocated = toFiniteNumber(data.diem_epoch_allocated);
|
|
910
|
+
if (diemBalance === undefined && usdBalance === undefined) return null;
|
|
911
|
+
const label = diemBalance !== undefined
|
|
912
|
+
? `DIEM balance (${Math.round(diemBalance)})`
|
|
913
|
+
: `USD balance ($${usdBalance?.toFixed(2) ?? "?"})`;
|
|
914
|
+
if (epochAllocated !== undefined && epochAllocated > 0 && epochUsed !== undefined) {
|
|
915
|
+
const percent = normalizePercent((epochUsed / epochAllocated) * 100);
|
|
916
|
+
if (percent === undefined) return null;
|
|
917
|
+
return report(provider, "venice:billing-balance", {
|
|
918
|
+
customWindows: [{ label, percent }],
|
|
919
|
+
updatedAt: Date.now(),
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
return report(provider, "venice:billing-balance", {
|
|
923
|
+
customWindows: [{ label, percent: 0 }],
|
|
924
|
+
updatedAt: Date.now(),
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Synthetic `GET /v2/quotas` — the known quota lanes (rolling 5-hour,
|
|
930
|
+
* weekly token, search-hourly) mapped onto the quota windows.
|
|
931
|
+
*/
|
|
932
|
+
async function fetchSyntheticQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
933
|
+
if (!isCanonicalSyntheticBaseUrl(config.baseUrl)) return null;
|
|
934
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
935
|
+
if (!apiKey) return null;
|
|
936
|
+
const response = await fetch(`${SYNTHETIC_BASE_URL}/quotas`, {
|
|
937
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
938
|
+
redirect: "error",
|
|
939
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
940
|
+
});
|
|
941
|
+
if (!response.ok) {
|
|
942
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
943
|
+
? TERMINAL_QUOTA_FAILURE
|
|
944
|
+
: null;
|
|
945
|
+
}
|
|
946
|
+
const body = asRecord(await readQuotaJson(response));
|
|
947
|
+
const data = asRecord(body?.data) ?? body;
|
|
948
|
+
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
949
|
+
let windows = 0;
|
|
950
|
+
const percentAt = (key: string): number | undefined => {
|
|
951
|
+
const value = normalizePercent(data?.[key]);
|
|
952
|
+
if (value !== undefined) return value;
|
|
953
|
+
const nested = asRecord(data?.quota) ?? asRecord(data?.quotas);
|
|
954
|
+
return nested ? normalizePercent(nested[key]) : undefined;
|
|
955
|
+
};
|
|
956
|
+
const fiveHour = percentAt("rollingFiveHourLimit");
|
|
957
|
+
const weekly = percentAt("weeklyTokenLimit");
|
|
958
|
+
if (fiveHour !== undefined) {
|
|
959
|
+
quota.fiveHourPercent = fiveHour;
|
|
960
|
+
windows += 1;
|
|
961
|
+
}
|
|
962
|
+
if (weekly !== undefined) {
|
|
963
|
+
quota.weeklyPercent = weekly;
|
|
964
|
+
windows += 1;
|
|
965
|
+
}
|
|
966
|
+
const search = asRecord(data?.search);
|
|
967
|
+
const searchHourly = search ? normalizePercent(search.hourly) : undefined;
|
|
968
|
+
if (searchHourly !== undefined) {
|
|
969
|
+
quota.customWindows = [...(quota.customWindows ?? []), { label: "Search hourly", percent: searchHourly }];
|
|
970
|
+
windows += 1;
|
|
971
|
+
}
|
|
972
|
+
return windows > 0 ? report(provider, "synthetic:quotas", quota) : null;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* DeepInfra `GET /payment/checklist?compute_owed=true` — prepaid balance,
|
|
977
|
+
* recent spend, spending limit, and suspension state. Renders a balance
|
|
978
|
+
* window (prepaid funds are a negative `stripe_balance` → positive available).
|
|
979
|
+
*/
|
|
980
|
+
async function fetchDeepInfraQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
981
|
+
if (!isCanonicalDeepInfraBaseUrl(config.baseUrl)) return null;
|
|
982
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
983
|
+
if (!apiKey) return null;
|
|
984
|
+
const response = await fetch(`${DEEPINFRA_BASE_URL}/payment/checklist?compute_owed=true`, {
|
|
985
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
986
|
+
redirect: "error",
|
|
987
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
988
|
+
});
|
|
989
|
+
if (!response.ok) {
|
|
990
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
991
|
+
? TERMINAL_QUOTA_FAILURE
|
|
992
|
+
: null;
|
|
993
|
+
}
|
|
994
|
+
const body = asRecord(await readQuotaJson(response));
|
|
995
|
+
const data = asRecord(body?.data) ?? body;
|
|
996
|
+
if (!data) return null;
|
|
997
|
+
const stripeBalance = toFiniteNumber(data.stripe_balance);
|
|
998
|
+
const spendLimit = toFiniteNumber(data.spending_limit);
|
|
999
|
+
const total = toFiniteNumber(data.total_amount_due);
|
|
1000
|
+
if (stripeBalance === undefined) return null;
|
|
1001
|
+
// Prepaid funds are negative; a positive value is money owed.
|
|
1002
|
+
const available = stripeBalance < 0 ? -stripeBalance : 0;
|
|
1003
|
+
if (spendLimit !== undefined && spendLimit > 0) {
|
|
1004
|
+
const spent = total !== undefined && total > 0 ? total : Math.max(0, spendLimit - available);
|
|
1005
|
+
const percent = normalizePercent((spent / spendLimit) * 100);
|
|
1006
|
+
if (percent === undefined) return null;
|
|
1007
|
+
return report(provider, "deepinfra:billing-checklist", {
|
|
1008
|
+
customWindows: [{ label: `Billing cycle spend ($${spent.toFixed(2)} of $${spendLimit.toFixed(2)})`, percent }],
|
|
1009
|
+
updatedAt: Date.now(),
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
return report(provider, "deepinfra:billing-checklist", {
|
|
1013
|
+
customWindows: [{ label: `Prepaid balance ($${available.toFixed(2)})`, percent: 0 }],
|
|
1014
|
+
updatedAt: Date.now(),
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Neuralwatt `GET /v1/quota` — subscription kWh usage (primary window) and
|
|
1020
|
+
* prepaid USD credit balance (secondary).
|
|
1021
|
+
*/
|
|
1022
|
+
async function fetchNeuralwattQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
1023
|
+
if (!isCanonicalNeuralwattBaseUrl(config.baseUrl)) return null;
|
|
1024
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
1025
|
+
if (!apiKey) return null;
|
|
1026
|
+
const response = await fetch(`${NEURALWATT_BASE_URL}/quota`, {
|
|
1027
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
1028
|
+
redirect: "error",
|
|
1029
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1030
|
+
});
|
|
1031
|
+
if (!response.ok) {
|
|
1032
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
1033
|
+
? TERMINAL_QUOTA_FAILURE
|
|
1034
|
+
: null;
|
|
1035
|
+
}
|
|
1036
|
+
const body = asRecord(await readQuotaJson(response));
|
|
1037
|
+
const data = asRecord(body?.data) ?? body;
|
|
1038
|
+
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
1039
|
+
let windows = 0;
|
|
1040
|
+
const subscription = asRecord(data?.subscription);
|
|
1041
|
+
const kwhUsed = subscription ? toFiniteNumber(subscription.kwh_used) : undefined;
|
|
1042
|
+
const kwhIncluded = subscription ? toFiniteNumber(subscription.kwh_included) : undefined;
|
|
1043
|
+
if (kwhUsed !== undefined && kwhIncluded !== undefined && kwhIncluded > 0) {
|
|
1044
|
+
const percent = normalizePercent((kwhUsed / kwhIncluded) * 100);
|
|
1045
|
+
if (percent !== undefined) {
|
|
1046
|
+
quota.fiveHourPercent = percent;
|
|
1047
|
+
const periodEnd = subscription ? normalizeResetAt(subscription.current_period_end) : undefined;
|
|
1048
|
+
if (periodEnd !== undefined) quota.fiveHourResetAt = periodEnd;
|
|
1049
|
+
windows += 1;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
const balance = asRecord(data?.balance);
|
|
1053
|
+
const totalCredits = balance ? toFiniteNumber(balance.total_credits_usd) : undefined;
|
|
1054
|
+
const remainingCredits = balance ? toFiniteNumber(balance.credits_remaining_usd) : undefined;
|
|
1055
|
+
if (totalCredits !== undefined && totalCredits > 0 && remainingCredits !== undefined) {
|
|
1056
|
+
// Utilization is CONSUMED credits, not the remaining share.
|
|
1057
|
+
const used = Math.max(0, totalCredits - remainingCredits);
|
|
1058
|
+
const percent = normalizePercent((used / totalCredits) * 100);
|
|
1059
|
+
if (percent !== undefined) {
|
|
1060
|
+
quota.customWindows = [...(quota.customWindows ?? []), { label: "Prepaid credits", percent }];
|
|
1061
|
+
windows += 1;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
return windows > 0 ? report(provider, "neuralwatt:quota", quota) : null;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function report(
|
|
1068
|
+
provider: string,
|
|
1069
|
+
source: string,
|
|
1070
|
+
quota: ProviderQuota,
|
|
1071
|
+
aggregation?: CodexCapacityAggregation,
|
|
1072
|
+
): ProviderQuotaReport | null {
|
|
127
1073
|
if (!hasQuotaRows(quota)) return null;
|
|
128
1074
|
return {
|
|
129
1075
|
provider,
|
|
@@ -131,27 +1077,92 @@ function report(provider: string, source: string, quota: ProviderQuota): Provide
|
|
|
131
1077
|
source,
|
|
132
1078
|
quota,
|
|
133
1079
|
updatedAt: quota.updatedAt,
|
|
1080
|
+
...(aggregation ? { aggregation } : {}),
|
|
134
1081
|
};
|
|
135
1082
|
}
|
|
136
1083
|
|
|
1084
|
+
function tagNativeMainReport(
|
|
1085
|
+
value: ProviderQuotaReport | null,
|
|
1086
|
+
generation: number,
|
|
1087
|
+
): ProviderQuotaReport | null {
|
|
1088
|
+
if (value) nativeMainReportGenerations.set(value, generation);
|
|
1089
|
+
return value;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolean {
|
|
1093
|
+
const generation = nativeMainReportGenerations.get(value);
|
|
1094
|
+
return generation === undefined || isMainAccountIdentityGenerationLive(generation);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
137
1097
|
async function fetchChatGptForwardQuota(
|
|
138
1098
|
config: OcxConfig,
|
|
139
1099
|
provider: string,
|
|
140
1100
|
providerConfig: OcxProviderConfig,
|
|
141
1101
|
forceRefresh: boolean,
|
|
1102
|
+
prefetchedSnapshot?: CodexAuthAccountsSnapshotPromise,
|
|
142
1103
|
): Promise<ProviderQuotaReport | null> {
|
|
143
1104
|
if (providerCodexAccountMode(provider, providerConfig) === "direct") {
|
|
144
|
-
const
|
|
145
|
-
const quota =
|
|
146
|
-
|
|
1105
|
+
const snapshot = await fetchMainAccountInfoSnapshot(forceRefresh);
|
|
1106
|
+
const quota = snapshot.info.quota
|
|
1107
|
+
? { ...snapshot.info.quota, updatedAt: Date.now() } as ProviderQuota
|
|
1108
|
+
: null;
|
|
1109
|
+
return quota
|
|
1110
|
+
? tagNativeMainReport(report(provider, "chatgpt:wham", quota), snapshot.mainIdentityGeneration)
|
|
1111
|
+
: null;
|
|
147
1112
|
}
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
const
|
|
1113
|
+
const snapshot = await (prefetchedSnapshot ?? listCodexAuthAccountsSnapshot(config, forceRefresh));
|
|
1114
|
+
const accounts = snapshot.accounts;
|
|
1115
|
+
const activeId = effectiveCodexAuthAccountId(config);
|
|
1116
|
+
const capacityAccounts = accounts.map(account => ({ ...account, active: account.id === activeId }));
|
|
1117
|
+
const active = capacityAccounts.find(account => account.active)
|
|
151
1118
|
?? accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)
|
|
152
1119
|
?? accounts[0];
|
|
153
|
-
const
|
|
154
|
-
|
|
1120
|
+
const now = Date.now();
|
|
1121
|
+
const capacity = aggregateCodexPoolCapacity(capacityAccounts, now);
|
|
1122
|
+
if (capacity.aggregation && capacity.quota) {
|
|
1123
|
+
return tagNativeMainReport(
|
|
1124
|
+
report(
|
|
1125
|
+
provider,
|
|
1126
|
+
"chatgpt:wham",
|
|
1127
|
+
capacity.quota as ProviderQuota,
|
|
1128
|
+
publicCapacityAggregation(capacity.aggregation, "aggregate"),
|
|
1129
|
+
),
|
|
1130
|
+
snapshot.mainIdentityGeneration,
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
const activeUsable = !!active && !active.paused && active.needsReauth !== true;
|
|
1134
|
+
const quota = activeUsable && active?.quota
|
|
1135
|
+
? { ...active.quota, updatedAt: active.quota.updatedAt ?? Date.now() } as CodexCapacityQuota
|
|
1136
|
+
: null;
|
|
1137
|
+
const quotaFresh = !!quota
|
|
1138
|
+
&& Number.isFinite(quota.updatedAt)
|
|
1139
|
+
&& now - quota.updatedAt < CODEX_CAPACITY_MAX_QUOTA_AGE_MS;
|
|
1140
|
+
if (quota && quotaFresh) {
|
|
1141
|
+
const fallback = report(
|
|
1142
|
+
provider,
|
|
1143
|
+
"chatgpt:wham",
|
|
1144
|
+
quota as ProviderQuota,
|
|
1145
|
+
capacity.aggregation
|
|
1146
|
+
? publicCapacityAggregation(capacity.aggregation, "effective-account-fallback")
|
|
1147
|
+
: undefined,
|
|
1148
|
+
);
|
|
1149
|
+
return tagNativeMainReport(fallback, snapshot.mainIdentityGeneration);
|
|
1150
|
+
}
|
|
1151
|
+
if (capacity.aggregation) {
|
|
1152
|
+
const updatedAt = Date.now();
|
|
1153
|
+
return tagNativeMainReport(
|
|
1154
|
+
{
|
|
1155
|
+
provider,
|
|
1156
|
+
label: providerLabel(provider),
|
|
1157
|
+
source: "chatgpt:wham",
|
|
1158
|
+
quota: { updatedAt },
|
|
1159
|
+
updatedAt,
|
|
1160
|
+
aggregation: publicCapacityAggregation(capacity.aggregation, "coverage-only"),
|
|
1161
|
+
},
|
|
1162
|
+
snapshot.mainIdentityGeneration,
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
return null;
|
|
155
1166
|
}
|
|
156
1167
|
|
|
157
1168
|
function centsValue(value: unknown): number | undefined {
|
|
@@ -475,7 +1486,7 @@ async function fetchAnthropicUsageQuota(accessToken: string): Promise<ProviderQu
|
|
|
475
1486
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
476
1487
|
});
|
|
477
1488
|
if (!response.ok) return null;
|
|
478
|
-
const body = asRecord(await response
|
|
1489
|
+
const body = asRecord(await readQuotaJson(response));
|
|
479
1490
|
if (!body) return null;
|
|
480
1491
|
const fiveHour = parseClaudeBucket(body.five_hour);
|
|
481
1492
|
const sevenDay = parseClaudeBucket(body.seven_day);
|
|
@@ -640,6 +1651,13 @@ export function reconcileProviderAccountQuotaRows(context: GenerationContext): n
|
|
|
640
1651
|
return removed;
|
|
641
1652
|
}
|
|
642
1653
|
|
|
1654
|
+
/** Test-only reset so a direct reconcile call in one file cannot leak across files. */
|
|
1655
|
+
export function resetProviderQuotaReconcileStateForTests(): void {
|
|
1656
|
+
lastReconciledGeneration = 0;
|
|
1657
|
+
liveAccountQuotaKeys = new Set();
|
|
1658
|
+
liveProviderQuotaKeys = new Set();
|
|
1659
|
+
}
|
|
1660
|
+
|
|
643
1661
|
/** Drop cached per-account rows (all, or just one provider's). */
|
|
644
1662
|
export function clearAccountQuotaCache(provider?: string): void {
|
|
645
1663
|
if (!provider) {
|
|
@@ -781,7 +1799,7 @@ export async function fetchProviderAccountQuotas(
|
|
|
781
1799
|
function normalizedBaseUrl(value: string): string | null {
|
|
782
1800
|
try {
|
|
783
1801
|
const url = new URL(value);
|
|
784
|
-
if (url.search || url.hash) return null;
|
|
1802
|
+
if (url.username || url.password || url.search || url.hash) return null;
|
|
785
1803
|
return `${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`;
|
|
786
1804
|
} catch {
|
|
787
1805
|
return null;
|
|
@@ -796,6 +1814,12 @@ function isCanonicalKimiCodeBaseUrl(baseUrl: string): boolean {
|
|
|
796
1814
|
return normalizedBaseUrl(baseUrl) === KIMI_CODE_BASE_URL;
|
|
797
1815
|
}
|
|
798
1816
|
|
|
1817
|
+
function isCanonicalCommandCodeBaseUrl(baseUrl: string): boolean {
|
|
1818
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
1819
|
+
// OAuth preset points at the API root; the Provider-API preset at /provider/v1.
|
|
1820
|
+
return normalized === COMMAND_CODE_BASE_URL || normalized === `${COMMAND_CODE_BASE_URL}/provider/v1`;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
799
1823
|
/** Prefer the nested `data` shell when the outer object is only an envelope. */
|
|
800
1824
|
function unwrapKimiQuotaPayload(value: unknown): Record<string, unknown> | null {
|
|
801
1825
|
const body = asRecord(value);
|
|
@@ -913,10 +1937,149 @@ async function fetchKimiQuota(provider: string, config: OcxProviderConfig): Prom
|
|
|
913
1937
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
914
1938
|
});
|
|
915
1939
|
if (!response.ok) return null;
|
|
916
|
-
const quota = parseKimiQuotaPayload(await response
|
|
1940
|
+
const quota = parseKimiQuotaPayload(await readQuotaJson(response));
|
|
917
1941
|
return quota ? report(provider, "kimi:usages", quota) : null;
|
|
918
1942
|
}
|
|
919
1943
|
|
|
1944
|
+
/**
|
|
1945
|
+
* Command Code rolling window: `{ cap, used, resetAt }` off /alpha/billing/credits,
|
|
1946
|
+
* normalized to a percent with an optional reset timestamp.
|
|
1947
|
+
*/
|
|
1948
|
+
function parseCommandCodeWindow(value: unknown): { percent: number; resetAt?: number } | null {
|
|
1949
|
+
const row = asRecord(value);
|
|
1950
|
+
if (!row) return null;
|
|
1951
|
+
const cap = toFiniteNumber(row.cap);
|
|
1952
|
+
const used = toFiniteNumber(row.used);
|
|
1953
|
+
if (cap === undefined || used === undefined || cap <= 0 || used < 0) return null;
|
|
1954
|
+
const percent = normalizePercent((used / cap) * 100);
|
|
1955
|
+
if (percent === undefined) return null;
|
|
1956
|
+
const resetAt = quotaResetAt(row);
|
|
1957
|
+
return { percent, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/** Soft-fail GET returning a parsed record, or null when unavailable. */
|
|
1961
|
+
async function fetchCommandCodeJson(url: string, bearer: string): Promise<Record<string, unknown> | null> {
|
|
1962
|
+
try {
|
|
1963
|
+
const response = await fetch(url, {
|
|
1964
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` },
|
|
1965
|
+
redirect: "error",
|
|
1966
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1967
|
+
});
|
|
1968
|
+
if (!response.ok) return null;
|
|
1969
|
+
return asRecord(await readQuotaJson(response));
|
|
1970
|
+
} catch {
|
|
1971
|
+
return null;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
/**
|
|
1976
|
+
* Soft-fail period spend (used) against the remaining credit pools → creditsUsd.
|
|
1977
|
+
* Period scoping: `since=<currentPeriodStart>` keeps spend aligned with the
|
|
1978
|
+
* pools' billing cycle, and `currentPeriodEnd` becomes expiresAt.
|
|
1979
|
+
*/
|
|
1980
|
+
async function fetchCommandCodeSpend(
|
|
1981
|
+
bearer: string,
|
|
1982
|
+
credits: Record<string, unknown> | null,
|
|
1983
|
+
orgQuery: string,
|
|
1984
|
+
): Promise<ProviderQuotaCreditsUsd | undefined> {
|
|
1985
|
+
if (!credits) return undefined;
|
|
1986
|
+
const subscriptionBody = await fetchCommandCodeJson(`${COMMAND_CODE_SUBSCRIPTIONS_URL}${orgQuery}`, bearer);
|
|
1987
|
+
const subscription = asRecord(subscriptionBody?.data) ?? subscriptionBody;
|
|
1988
|
+
const periodStart = typeof subscription?.currentPeriodStart === "string" ? subscription.currentPeriodStart.trim() : "";
|
|
1989
|
+
// Unscoped /usage/summary is lifetime spend; mixing it with current-cycle
|
|
1990
|
+
// remaining pools produces a wrong percent. Omit creditsUsd until a period exists.
|
|
1991
|
+
if (!periodStart) return undefined;
|
|
1992
|
+
const sinceQuery = `${orgQuery ? "&" : "?"}since=${encodeURIComponent(periodStart)}`;
|
|
1993
|
+
const expiresAt = normalizeResetAt(subscription?.currentPeriodEnd);
|
|
1994
|
+
const summaryBody = await fetchCommandCodeJson(`${COMMAND_CODE_USAGE_URL}${orgQuery}${sinceQuery}`, bearer);
|
|
1995
|
+
const summary = asRecord(summaryBody?.data) ?? summaryBody;
|
|
1996
|
+
const used = toFiniteNumber(summary?.totalCost) ?? toFiniteNumber(summary?.totalMonthlyCredits);
|
|
1997
|
+
if (used === undefined || used < 0) return undefined;
|
|
1998
|
+
const pools = [credits.monthlyCredits, credits.purchasedCredits, credits.freeCredits]
|
|
1999
|
+
.map(value => toFiniteNumber(value))
|
|
2000
|
+
.filter((value): value is number => value !== undefined);
|
|
2001
|
+
// Field presence is what separates a real balance from absent data: an exhausted
|
|
2002
|
+
// all-zero account still reports remaining=0, while no remaining-credit field at
|
|
2003
|
+
// all means there is nothing to meter.
|
|
2004
|
+
if (pools.length === 0) return undefined;
|
|
2005
|
+
const remaining = pools.reduce((sum, value) => sum + Math.max(0, value ?? 0), 0);
|
|
2006
|
+
const limit = used + remaining;
|
|
2007
|
+
const percent = normalizePercent(limit > 0 ? (used / limit) * 100 : 0);
|
|
2008
|
+
// Purchased credits roll over past the subscription period end, so an expiry is
|
|
2009
|
+
// only truthful when the aggregate contains no non-expiring purchased pool.
|
|
2010
|
+
const purchased = toFiniteNumber(credits.purchasedCredits) ?? 0;
|
|
2011
|
+
return percent === undefined
|
|
2012
|
+
? undefined
|
|
2013
|
+
: {
|
|
2014
|
+
used,
|
|
2015
|
+
limit,
|
|
2016
|
+
remaining,
|
|
2017
|
+
percent,
|
|
2018
|
+
...(expiresAt !== undefined && purchased <= 0 ? { expiresAt } : {}),
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
/** OAuth access token or ACTIVE Provider-API key for the Command Code quota probe. */
|
|
2023
|
+
async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig): Promise<string | null> {
|
|
2024
|
+
if (config.authMode === "oauth") {
|
|
2025
|
+
try {
|
|
2026
|
+
return await getValidAccessToken("command-code");
|
|
2027
|
+
} catch {
|
|
2028
|
+
return null;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
// ACTIVE key only: a quota bar for a different account than the one routing
|
|
2032
|
+
// requests is a wrong meter, not a helpful one.
|
|
2033
|
+
return resolveEnvValue(config.apiKey)?.trim() || null;
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/**
|
|
2037
|
+
* Command Code `GET /alpha/billing/credits` — the same Bearer surface the CLI's
|
|
2038
|
+
* usage view uses (windowLimits.fiveHour / windowLimits.weekly), plus soft
|
|
2039
|
+
* whoami (team orgId scoping) and subscription-scoped spend for creditsUsd.
|
|
2040
|
+
*/
|
|
2041
|
+
async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
2042
|
+
// Never release credentials to a user-edited or lookalike provider host.
|
|
2043
|
+
if (!isCanonicalCommandCodeBaseUrl(config.baseUrl)) return null;
|
|
2044
|
+
const bearer = await resolveCommandCodeQuotaBearer(config);
|
|
2045
|
+
if (!bearer) return null;
|
|
2046
|
+
const whoamiBody = await fetchCommandCodeJson(COMMAND_CODE_WHOAMI_URL, bearer);
|
|
2047
|
+
const whoami = asRecord(whoamiBody?.data) ?? whoamiBody;
|
|
2048
|
+
const org = asRecord(whoami?.org);
|
|
2049
|
+
const orgId = typeof org?.id === "string" && org.id.trim() ? org.id.trim() : null;
|
|
2050
|
+
const orgQuery = orgId ? `?orgId=${encodeURIComponent(orgId)}` : "";
|
|
2051
|
+
const response = await fetch(`${COMMAND_CODE_CREDITS_URL}${orgQuery}`, {
|
|
2052
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` },
|
|
2053
|
+
redirect: "error",
|
|
2054
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
2055
|
+
});
|
|
2056
|
+
if (!response.ok) {
|
|
2057
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
2058
|
+
? TERMINAL_QUOTA_FAILURE
|
|
2059
|
+
: null;
|
|
2060
|
+
}
|
|
2061
|
+
const raw = asRecord(await readQuotaJson(response));
|
|
2062
|
+
const body = asRecord(raw?.data) ?? raw;
|
|
2063
|
+
const credits = asRecord(body?.credits);
|
|
2064
|
+
const limits = asRecord(body?.windowLimits);
|
|
2065
|
+
if (!credits && !limits) return null;
|
|
2066
|
+
const fiveHour = parseCommandCodeWindow(limits?.fiveHour);
|
|
2067
|
+
const weekly = parseCommandCodeWindow(limits?.weekly);
|
|
2068
|
+
const creditsUsd = await fetchCommandCodeSpend(bearer, credits, orgQuery);
|
|
2069
|
+
return report(provider, "command-code:credits", {
|
|
2070
|
+
...(fiveHour ? {
|
|
2071
|
+
fiveHourPercent: fiveHour.percent,
|
|
2072
|
+
...(fiveHour.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}),
|
|
2073
|
+
} : {}),
|
|
2074
|
+
...(weekly ? {
|
|
2075
|
+
weeklyPercent: weekly.percent,
|
|
2076
|
+
...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}),
|
|
2077
|
+
} : {}),
|
|
2078
|
+
...(creditsUsd ? { creditsUsd } : {}),
|
|
2079
|
+
updatedAt: Date.now(),
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
|
|
920
2083
|
/** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */
|
|
921
2084
|
async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
922
2085
|
let accessToken: string;
|
|
@@ -946,7 +2109,7 @@ async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport |
|
|
|
946
2109
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
947
2110
|
});
|
|
948
2111
|
if (periodRes.ok) {
|
|
949
|
-
const body = asRecord(await periodRes
|
|
2112
|
+
const body = asRecord(await readQuotaJson(periodRes));
|
|
950
2113
|
const planUsage = asRecord(body?.planUsage);
|
|
951
2114
|
if (planUsage) {
|
|
952
2115
|
const resetAt = normalizeResetAt(body?.billingCycleEnd ?? planUsage.billingCycleEnd ?? body?.periodEnd);
|
|
@@ -1008,7 +2171,7 @@ async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport |
|
|
|
1008
2171
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1009
2172
|
});
|
|
1010
2173
|
if (summaryRes.ok) {
|
|
1011
|
-
const body = asRecord(await summaryRes
|
|
2174
|
+
const body = asRecord(await readQuotaJson(summaryRes));
|
|
1012
2175
|
const individual = asRecord(body?.individualUsage);
|
|
1013
2176
|
const plan = asRecord(individual?.plan);
|
|
1014
2177
|
if (plan) {
|
|
@@ -1037,7 +2200,7 @@ async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport |
|
|
|
1037
2200
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1038
2201
|
});
|
|
1039
2202
|
if (!response.ok) return null;
|
|
1040
|
-
const body = asRecord(await response
|
|
2203
|
+
const body = asRecord(await readQuotaJson(response));
|
|
1041
2204
|
if (!body) return null;
|
|
1042
2205
|
|
|
1043
2206
|
// Prefer the gpt-4 bucket (historical "fast requests"); else first model with used+limit.
|
|
@@ -1151,7 +2314,7 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig
|
|
|
1151
2314
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1152
2315
|
});
|
|
1153
2316
|
if (!response.ok) return null;
|
|
1154
|
-
const body = asRecord(await response
|
|
2317
|
+
const body = asRecord(await readQuotaJson(response));
|
|
1155
2318
|
const models = asRecord(body?.models);
|
|
1156
2319
|
if (!models) return null;
|
|
1157
2320
|
|
|
@@ -1188,10 +2351,13 @@ async function maybeFetchProviderQuota(
|
|
|
1188
2351
|
provider: OcxProviderConfig,
|
|
1189
2352
|
config: OcxConfig,
|
|
1190
2353
|
forceRefresh: boolean,
|
|
1191
|
-
|
|
2354
|
+
prefetchedCodexSnapshot?: CodexAuthAccountsSnapshotPromise,
|
|
2355
|
+
): Promise<ProviderQuotaProbeResult> {
|
|
1192
2356
|
if (provider.disabled === true) return null;
|
|
1193
2357
|
try {
|
|
1194
|
-
if (isBuiltInChatGptForwardProvider(name, provider))
|
|
2358
|
+
if (isBuiltInChatGptForwardProvider(name, provider)) {
|
|
2359
|
+
return fetchChatGptForwardQuota(config, name, provider, forceRefresh, prefetchedCodexSnapshot);
|
|
2360
|
+
}
|
|
1195
2361
|
if (provider.authMode === "oauth" && name === "xai") return fetchXaiQuota(name);
|
|
1196
2362
|
if (provider.authMode === "oauth" && name === "anthropic") return fetchAnthropicQuota(name);
|
|
1197
2363
|
if (provider.authMode === "oauth" && name === "cursor") return fetchCursorQuota(name);
|
|
@@ -1202,6 +2368,59 @@ async function maybeFetchProviderQuota(
|
|
|
1202
2368
|
if (provider.authMode === "key" && isCanonicalKimiCodeBaseUrl(provider.baseUrl)) {
|
|
1203
2369
|
return fetchKimiQuota(name, provider);
|
|
1204
2370
|
}
|
|
2371
|
+
// OAuth account login or Provider-API key only; forward/local modes carry no
|
|
2372
|
+
// credential of ours on the canonical host.
|
|
2373
|
+
if (provider.authMode === "oauth" && name === "command-code") {
|
|
2374
|
+
return fetchCommandCodeQuota(name, provider);
|
|
2375
|
+
}
|
|
2376
|
+
if ((provider.authMode ?? "key") === "key" && name === "commandcode"
|
|
2377
|
+
&& isCanonicalCommandCodeBaseUrl(provider.baseUrl)) {
|
|
2378
|
+
return fetchCommandCodeQuota(name, provider);
|
|
2379
|
+
}
|
|
2380
|
+
// Identify OpenCode Go by where it routes, not by what the row is called. Multi-account
|
|
2381
|
+
// setups keep the same destination under names like `opencode-go-2` (#1924), and those rows
|
|
2382
|
+
// silently had no quota panel and no `ocx provider quota --json` report while the literal
|
|
2383
|
+
// name was the gate. `registryEntryForProviderDestination` is the existing predicate for
|
|
2384
|
+
// exactly this question: normalized endpoint + adapter + key auth, so a canonical URL behind
|
|
2385
|
+
// a different adapter is still not OpenCode Go. The defensive URL check inside
|
|
2386
|
+
// `fetchOpenCodeGoQuota` stays — sending a key anywhere must not depend on this gate.
|
|
2387
|
+
if ((provider.authMode ?? "key") === "key" && registryEntryForProviderDestination(provider)?.id === "opencode-go") {
|
|
2388
|
+
return fetchOpenCodeGoQuota(name, provider);
|
|
2389
|
+
}
|
|
2390
|
+
if ((provider.authMode ?? "key") === "key" && isCanonicalA6apiBaseUrl(provider.baseUrl)) {
|
|
2391
|
+
return fetchA6apiQuota(name, provider);
|
|
2392
|
+
}
|
|
2393
|
+
if ((provider.authMode ?? "key") === "key" && name === "openrouter") {
|
|
2394
|
+
return fetchOpenRouterQuota(name, provider);
|
|
2395
|
+
}
|
|
2396
|
+
if ((provider.authMode ?? "key") === "key" && name === "deepseek") {
|
|
2397
|
+
return fetchDeepSeekQuota(name, provider);
|
|
2398
|
+
}
|
|
2399
|
+
if ((provider.authMode ?? "key") === "key" && name === "cline-pass") {
|
|
2400
|
+
return fetchClineQuota(name, provider);
|
|
2401
|
+
}
|
|
2402
|
+
if ((provider.authMode ?? "key") === "key"
|
|
2403
|
+
&& (name === "zai" || name === "glm" || name === "glm-cn" || name === "zhipu-bigmodel-coding")) {
|
|
2404
|
+
return fetchZaiQuota(name, provider);
|
|
2405
|
+
}
|
|
2406
|
+
if ((provider.authMode ?? "key") === "key" && (name === "minimax" || name === "minimax-cn")) {
|
|
2407
|
+
return fetchMinimaxQuota(name, provider);
|
|
2408
|
+
}
|
|
2409
|
+
if ((provider.authMode ?? "key") === "key" && name === "moonshot") {
|
|
2410
|
+
return fetchMoonshotQuota(name, provider);
|
|
2411
|
+
}
|
|
2412
|
+
if ((provider.authMode ?? "key") === "key" && name === "venice") {
|
|
2413
|
+
return fetchVeniceQuota(name, provider);
|
|
2414
|
+
}
|
|
2415
|
+
if ((provider.authMode ?? "key") === "key" && name === "synthetic") {
|
|
2416
|
+
return fetchSyntheticQuota(name, provider);
|
|
2417
|
+
}
|
|
2418
|
+
if ((provider.authMode ?? "key") === "key" && name === "deepinfra") {
|
|
2419
|
+
return fetchDeepInfraQuota(name, provider);
|
|
2420
|
+
}
|
|
2421
|
+
if ((provider.authMode ?? "key") === "key" && name === "neuralwatt") {
|
|
2422
|
+
return fetchNeuralwattQuota(name, provider);
|
|
2423
|
+
}
|
|
1205
2424
|
return null;
|
|
1206
2425
|
} catch {
|
|
1207
2426
|
return null;
|
|
@@ -1209,41 +2428,96 @@ async function maybeFetchProviderQuota(
|
|
|
1209
2428
|
}
|
|
1210
2429
|
|
|
1211
2430
|
export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh = false): Promise<ProviderQuotaResponse> {
|
|
1212
|
-
|
|
2431
|
+
// A Pool report's cache signature and provider fetch must share one account snapshot.
|
|
2432
|
+
// Preserve force semantics when deciding whether that snapshot refreshes upstream data.
|
|
2433
|
+
const prefetchedCodexSnapshot = hasCodexPoolProvider(config)
|
|
2434
|
+
? listCodexAuthAccountsSnapshot(config, forceRefresh)
|
|
2435
|
+
: undefined;
|
|
2436
|
+
const keyCandidate = cacheKeyWithAggregationState(config, prefetchedCodexSnapshot);
|
|
2437
|
+
const key = typeof keyCandidate === "string" ? keyCandidate : await keyCandidate;
|
|
1213
2438
|
const writerGeneration = captureConfigGeneration();
|
|
1214
2439
|
const now = Date.now();
|
|
1215
2440
|
// The cache fast path must not extend a preserved last-good row past its 30-minute bound:
|
|
1216
2441
|
// a row preserved at age 29:59 plus a full 5-minute TTL would otherwise serve until ~35min.
|
|
1217
2442
|
const cacheFresh = cache && cache.key === key && now - cache.ts < CACHE_TTL_MS
|
|
1218
|
-
&& cache.response.reports.every(item =>
|
|
2443
|
+
&& cache.response.reports.every(item =>
|
|
2444
|
+
now - item.updatedAt < LAST_GOOD_MAX_AGE_MS && isProviderQuotaReportCurrent(item));
|
|
1219
2445
|
if (!forceRefresh && cacheFresh) return cache!.response;
|
|
2446
|
+
|
|
2447
|
+
// Stale-while-revalidate for the Providers overview: when the TTL has expired but last-good
|
|
2448
|
+
// rows are still within LAST_GOOD_MAX_AGE_MS, paint them immediately and refresh in the
|
|
2449
|
+
// background. A cold load with no cache still waits on the probe (nothing to show yet).
|
|
1220
2450
|
const joinable = inflight.get(key);
|
|
1221
|
-
if (!forceRefresh && joinable && joinable.epoch === invalidationEpoch)
|
|
2451
|
+
if (!forceRefresh && joinable && joinable.epoch === invalidationEpoch) {
|
|
2452
|
+
if (cache && cache.key === key
|
|
2453
|
+
&& cache.response.reports.every(item => now - item.updatedAt < LAST_GOOD_MAX_AGE_MS)) {
|
|
2454
|
+
return cache.response;
|
|
2455
|
+
}
|
|
2456
|
+
return joinable.promise;
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
const staleUsable = !forceRefresh
|
|
2460
|
+
&& cache
|
|
2461
|
+
&& cache.key === key
|
|
2462
|
+
&& cache.response.reports.every(item => now - item.updatedAt < LAST_GOOD_MAX_AGE_MS);
|
|
2463
|
+
|
|
1222
2464
|
// A forced probe takes commit authority: older in-flight probes must not overwrite its result.
|
|
1223
2465
|
if (forceRefresh) invalidationEpoch += 1;
|
|
1224
2466
|
const epoch = invalidationEpoch;
|
|
1225
2467
|
|
|
1226
2468
|
const promise = (async (): Promise<ProviderQuotaResponse> => {
|
|
1227
2469
|
const previous = cache && cache.key === key ? cache.response.reports : [];
|
|
1228
|
-
const
|
|
1229
|
-
Object.entries(config.providers).map(([name, provider]) =>
|
|
1230
|
-
|
|
2470
|
+
const probeResults = await Promise.all(
|
|
2471
|
+
Object.entries(config.providers).map(([name, provider]) => (
|
|
2472
|
+
maybeFetchProviderQuota(name, provider, config, forceRefresh, prefetchedCodexSnapshot)
|
|
2473
|
+
)),
|
|
2474
|
+
);
|
|
2475
|
+
const fresh = probeResults.filter((item): item is ProviderQuotaReport => item !== null && item !== TERMINAL_QUOTA_FAILURE);
|
|
2476
|
+
const terminalFailures = new Set(
|
|
2477
|
+
Object.keys(config.providers).filter((_, index) => probeResults[index] === TERMINAL_QUOTA_FAILURE),
|
|
2478
|
+
);
|
|
2479
|
+
await providerQuotaBeforePublishForTests?.();
|
|
2480
|
+
let commitKey: string | null = null;
|
|
2481
|
+
if (epoch === invalidationEpoch) {
|
|
2482
|
+
const commitKeyCandidate = cacheKeyWithAggregationState(config);
|
|
2483
|
+
commitKey = typeof commitKeyCandidate === "string" ? commitKeyCandidate : await commitKeyCandidate;
|
|
2484
|
+
}
|
|
1231
2485
|
|
|
1232
|
-
// Keep bounded last-good rows when a probe fails
|
|
1233
|
-
//
|
|
2486
|
+
// Keep bounded last-good rows when a probe fails transiently; terminal-invalid provider
|
|
2487
|
+
// responses explicitly suppress their old row. Never re-stamp preserved timestamps.
|
|
1234
2488
|
// Note: the cache key encodes the provider set (name/adapter/authMode/disabled/baseUrl),
|
|
1235
2489
|
// so previous rows always correspond to currently configured, enabled providers — a
|
|
1236
2490
|
// disabled or removed provider changes the key and starts from an empty previous set.
|
|
1237
2491
|
const cutoff = Date.now() - LAST_GOOD_MAX_AGE_MS;
|
|
1238
2492
|
const byProvider = new Map<string, ProviderQuotaReport>();
|
|
2493
|
+
const generationMismatchedProviders = new Set<string>();
|
|
1239
2494
|
for (const item of previous) {
|
|
1240
|
-
if (item.updatedAt
|
|
2495
|
+
if (item.updatedAt < cutoff) continue;
|
|
2496
|
+
if (isProviderQuotaReportCurrent(item)) byProvider.set(item.provider, item);
|
|
2497
|
+
else generationMismatchedProviders.add(item.provider);
|
|
2498
|
+
}
|
|
2499
|
+
for (const item of fresh) {
|
|
2500
|
+
if (isProviderQuotaReportCurrent(item)) {
|
|
2501
|
+
byProvider.set(item.provider, item);
|
|
2502
|
+
generationMismatchedProviders.delete(item.provider);
|
|
2503
|
+
} else {
|
|
2504
|
+
byProvider.delete(item.provider);
|
|
2505
|
+
generationMismatchedProviders.add(item.provider);
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
// Terminal-invalid probes suppress their previous row (transient failures keep it).
|
|
2509
|
+
for (const provider of terminalFailures) {
|
|
2510
|
+
byProvider.delete(provider);
|
|
2511
|
+
generationMismatchedProviders.delete(provider);
|
|
1241
2512
|
}
|
|
1242
|
-
for (const item of fresh) byProvider.set(item.provider, item);
|
|
1243
2513
|
|
|
1244
2514
|
const response = { generatedAt: Date.now(), reports: [...byProvider.values()] };
|
|
1245
2515
|
// Commit only when this probe still holds authority (no clear/force superseded it).
|
|
1246
|
-
if (
|
|
2516
|
+
if (
|
|
2517
|
+
epoch === invalidationEpoch
|
|
2518
|
+
&& commitKey === key
|
|
2519
|
+
&& generationMismatchedProviders.size === 0
|
|
2520
|
+
) {
|
|
1247
2521
|
const reports = response.reports.filter(item => mayCommitProviderQuotaKey(item.provider, writerGeneration));
|
|
1248
2522
|
cache = { key, ts: Date.now(), response: { ...response, reports } };
|
|
1249
2523
|
}
|
|
@@ -1252,6 +2526,13 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh
|
|
|
1252
2526
|
|
|
1253
2527
|
const entry = { epoch, promise };
|
|
1254
2528
|
inflight.set(key, entry);
|
|
2529
|
+
if (staleUsable) {
|
|
2530
|
+
// Detach: do not await. Callers get last-good now; the flight still fills the cache.
|
|
2531
|
+
void promise.finally(() => {
|
|
2532
|
+
if (inflight.get(key) === entry) inflight.delete(key);
|
|
2533
|
+
});
|
|
2534
|
+
return cache!.response;
|
|
2535
|
+
}
|
|
1255
2536
|
try {
|
|
1256
2537
|
return await promise;
|
|
1257
2538
|
} finally {
|