@iislee/opencodex 2.11.0 → 2.35.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 +117 -22
- package/bin/ocx.mjs +245 -88
- package/bin/package-main.mjs +1 -1
- package/gui/dist/assets/index-Be1sDpWw.css +1 -0
- package/gui/dist/assets/index-D4OKgUV4.js +114 -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 +21 -10
- package/src/adapters/anthropic-output-schema.ts +137 -0
- package/src/adapters/anthropic.ts +475 -62
- package/src/adapters/base.ts +82 -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 +637 -0
- package/src/adapters/cursor/call-id.ts +44 -0
- package/src/adapters/cursor/catalog.ts +541 -0
- package/src/adapters/cursor/checkpoint-store.ts +308 -0
- package/src/adapters/cursor/cursor-errors.ts +144 -6
- package/src/adapters/cursor/discovery.ts +122 -14
- package/src/adapters/cursor/effort-map.ts +106 -4
- package/src/adapters/cursor/envelope-echo.ts +290 -0
- 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 +180 -59
- package/src/adapters/cursor/live-transport.ts +623 -170
- package/src/adapters/cursor/message-mapper.ts +4 -1
- package/src/adapters/cursor/native-exec-common.ts +23 -2
- package/src/adapters/cursor/native-exec-desktop.ts +23 -0
- 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 +5 -3
- package/src/adapters/cursor/native-exec.ts +121 -14
- package/src/adapters/cursor/protobuf-events.ts +829 -11
- package/src/adapters/cursor/protobuf-request.ts +447 -75
- package/src/adapters/cursor/request-builder.ts +271 -35
- package/src/adapters/cursor/tool-definitions.ts +252 -12
- package/src/adapters/cursor/tool-result-normalize.ts +115 -0
- package/src/adapters/cursor/transport.ts +22 -0
- package/src/adapters/cursor/types.ts +48 -1
- package/src/adapters/cursor.ts +336 -39
- package/src/adapters/exec-tool-result-normalize.ts +99 -0
- package/src/adapters/google-antigravity-replay.ts +713 -41
- package/src/adapters/google-antigravity-wire.ts +48 -10
- package/src/adapters/google-errors.ts +44 -12
- 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 +742 -86
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/image.ts +1 -1
- package/src/adapters/kiro-constants.ts +27 -0
- 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 +65 -17
- package/src/adapters/kiro.ts +270 -73
- package/src/adapters/mimo-free.ts +17 -0
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +1377 -344
- package/src/adapters/openai-responses-url.ts +16 -0
- package/src/adapters/openai-responses.ts +1164 -64
- package/src/adapters/registry.ts +175 -0
- package/src/adapters/responses-tool-schema.ts +67 -0
- package/src/adapters/run-turn-queue.ts +36 -1
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +105 -21
- package/src/adapters/xai-tool-schema.ts +436 -0
- package/src/adapters/xai-web-search.ts +186 -0
- package/src/bridge.ts +512 -92
- package/src/chat/inbound.ts +57 -20
- package/src/chat/outbound.ts +97 -33
- package/src/claude/agents-inject.ts +40 -10
- package/src/claude/context-windows.ts +37 -14
- 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 +88 -21
- package/src/cli/access.ts +46 -3
- package/src/cli/account-api.ts +150 -19
- package/src/cli/account-auth.ts +33 -6
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +648 -28
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +97 -19
- package/src/cli/agent.ts +53 -2
- package/src/cli/alias.ts +66 -0
- package/src/cli/capabilities-command.ts +94 -0
- package/src/cli/capabilities.ts +496 -0
- package/src/cli/claude-agent-startup-sync.ts +73 -0
- package/src/cli/claude-desktop.ts +60 -15
- package/src/cli/claude.ts +129 -35
- 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 +821 -0
- package/src/cli/doctor.ts +440 -56
- package/src/cli/ensure-desired-integrations.ts +152 -0
- package/src/cli/export-command.ts +46 -20
- package/src/cli/help.ts +34 -274
- package/src/cli/index.ts +417 -528
- package/src/cli/init.ts +5 -17
- package/src/cli/inspect.ts +230 -0
- 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 +130 -2
- package/src/cli/models.ts +118 -16
- package/src/cli/observe.ts +123 -11
- package/src/cli/opencode.ts +4 -2
- package/src/cli/provider-runtime.ts +34 -3
- package/src/cli/provider.ts +25 -3
- package/src/cli/ready.ts +301 -0
- package/src/cli/registry.ts +476 -0
- package/src/cli/root.ts +86 -0
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +57 -10
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +51 -4
- package/src/cli/storage.ts +234 -0
- package/src/cli/system-command.ts +16 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/cli/usage-report.ts +184 -0
- package/src/cli/v2.ts +137 -18
- package/src/cli/version-skew.ts +46 -0
- package/src/cli.ts +1 -1
- package/src/clients/config-export.ts +1442 -23
- package/src/clients/effective-status.ts +30 -2
- package/src/clients/probes/cline.ts +81 -0
- package/src/codex/account-label.ts +35 -1
- package/src/codex/account-lifecycle.ts +130 -13
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-pause.ts +2 -1
- package/src/codex/account-priority.ts +84 -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 +570 -107
- package/src/codex/app-server-restart-service.ts +232 -0
- package/src/codex/auth-api.ts +893 -246
- package/src/codex/auth-collision.ts +5 -3
- package/src/codex/auth-context.ts +348 -32
- package/src/codex/autostart-health.ts +24 -1
- package/src/codex/catalog/account-models.ts +67 -0
- package/src/codex/catalog/aggregation.ts +86 -10
- package/src/codex/catalog/bundled.ts +331 -33
- package/src/codex/catalog/effort.ts +138 -32
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +555 -45
- package/src/codex/catalog/native-models.ts +75 -0
- package/src/codex/catalog/parsing.ts +269 -30
- package/src/codex/catalog/provider-fetch.ts +1626 -144
- package/src/codex/catalog/sync.ts +1576 -187
- 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 +386 -0
- package/src/codex/convergence-types.ts +614 -0
- package/src/codex/convergence.ts +676 -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/desktop-app-restart.ts +355 -0
- package/src/codex/features.ts +636 -39
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +436 -0
- package/src/codex/history-lock.ts +242 -0
- package/src/codex/history-manifest.ts +112 -0
- package/src/codex/history-migration-guardian.ts +30 -24
- package/src/codex/history-provider.ts +1016 -235
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +223 -0
- package/src/codex/inject-coordination.ts +373 -0
- package/src/codex/inject.ts +1114 -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 +81 -0
- package/src/codex/journal.ts +66 -4
- package/src/codex/log-guard/inspect.ts +524 -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 +360 -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 +622 -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 +675 -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 +1313 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/prompt-text-probe.ts +238 -0
- package/src/codex/quota-rejection.ts +298 -0
- package/src/codex/quota.ts +264 -21
- package/src/codex/refresh.ts +11 -2
- package/src/codex/reset-credit-recovery.ts +1044 -0
- package/src/codex/routing.ts +514 -94
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +1065 -31
- package/src/codex/subagent-model-fallback.ts +437 -43
- package/src/codex/sync.ts +191 -2
- package/src/codex/transition-state.ts +720 -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 +47 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/request.ts +32 -0
- package/src/combos/types.ts +91 -9
- package/src/compatibility/index.ts +26 -0
- package/src/compatibility/manifest.ts +253 -0
- package/src/compatibility/openai-responses.ts +121 -0
- package/src/config/atomic-write.ts +219 -0
- package/src/config/paths.ts +40 -0
- package/src/config/process-state.ts +309 -0
- package/src/config/provider-name.ts +24 -0
- package/src/config/provider-validation.ts +177 -0
- package/src/config/rebase-provenance.ts +68 -0
- package/src/config.ts +1713 -709
- package/src/generated/compatibility-version.json +3324 -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 +157 -33
- package/src/images/plan.ts +24 -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-policy.ts +160 -0
- package/src/integrations/ownership.ts +155 -0
- package/src/integrations/registry.ts +166 -0
- package/src/integrations/serialize.ts +314 -0
- package/src/integrations/state.ts +433 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer-lock.ts +98 -0
- package/src/integrations/writer.ts +715 -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/admin-secrets.ts +24 -0
- package/src/lib/app-owned-memory-stores.ts +22 -0
- package/src/lib/bounded-body.ts +167 -11
- 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 +26 -2
- package/src/lib/destination-policy.ts +65 -1
- package/src/lib/errors.ts +80 -5
- 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/package-tree-integrity.ts +101 -0
- package/src/lib/pinned-http.ts +145 -26
- package/src/lib/process-control.ts +6 -2
- 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/service-secrets.ts +15 -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 +253 -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 +30 -4
- package/src/oauth/chatgpt.ts +12 -1
- package/src/oauth/cline.ts +203 -0
- package/src/oauth/command-code.ts +239 -0
- package/src/oauth/cursor.ts +46 -5
- package/src/oauth/generic-account-failover.ts +231 -0
- package/src/oauth/google-antigravity.ts +35 -3
- package/src/oauth/health.ts +20 -12
- package/src/oauth/index.ts +462 -71
- package/src/oauth/key-providers.ts +25 -0
- package/src/oauth/kimi.ts +25 -3
- package/src/oauth/kiro.ts +95 -6
- package/src/oauth/local-token-detect.ts +13 -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/open-browser-choice.ts +26 -0
- package/src/oauth/store.ts +133 -26
- package/src/oauth/token-guardian.ts +9 -3
- package/src/oauth/types.ts +15 -0
- 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/auto-compact-budget.ts +65 -0
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/codex-capacity.ts +292 -0
- package/src/providers/command-code-efforts.ts +176 -0
- package/src/providers/context-cap.ts +26 -8
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/default-aliases.ts +65 -0
- package/src/providers/derive.ts +281 -6
- package/src/providers/fastwire.ts +507 -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 +35 -2
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +115 -22
- package/src/providers/model-presets.ts +119 -0
- package/src/providers/model-rename-migration.ts +255 -0
- package/src/providers/model-rename-startup.ts +28 -0
- package/src/providers/new-model-policy.ts +146 -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 +30 -0
- package/src/providers/quota.ts +1379 -40
- package/src/providers/registry.ts +1545 -112
- package/src/providers/request-pacing.ts +310 -0
- package/src/providers/service-tier.ts +277 -0
- package/src/providers/slug-codec.ts +94 -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 +32 -4
- package/src/reasoning-effort.ts +68 -3
- package/src/responses/apply-patch-envelope.ts +63 -0
- package/src/responses/code-mode-helper-compat.ts +50 -0
- package/src/responses/compaction.ts +26 -1
- package/src/responses/custom-tool-compat.ts +384 -0
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/namespace-tool-compat.ts +435 -0
- package/src/responses/parser.ts +225 -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 +504 -0
- package/src/responses/schema.ts +12 -2
- package/src/responses/spill-store.ts +75 -10
- package/src/responses/state.ts +659 -31
- package/src/responses/thought-signature-replay.ts +364 -0
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/responses/truncated-stop-reason.ts +60 -0
- package/src/responses/turn-termination.ts +107 -0
- package/src/router.ts +405 -31
- package/src/routing/analytics.ts +379 -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 +155 -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 +289 -48
- package/src/server/background-lifecycle.ts +182 -0
- package/src/server/chat-completions.ts +154 -72
- package/src/server/chat-native-sse.ts +331 -0
- package/src/server/chat-native.ts +445 -0
- package/src/server/claude-messages.ts +160 -44
- 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 +1021 -190
- package/src/server/lifecycle.ts +336 -14
- 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 +433 -116
- package/src/server/management/api-key-usage.ts +31 -5
- package/src/server/management/body.ts +6 -0
- package/src/server/management/codex-prompt-routes.ts +570 -0
- package/src/server/management/combo-routes.ts +64 -25
- package/src/server/management/config-routes.ts +511 -51
- package/src/server/management/context.ts +89 -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 +127 -52
- package/src/server/management/model-routes.ts +461 -133
- package/src/server/management/model-rows.ts +167 -0
- package/src/server/management/native-integration-routes.ts +770 -0
- package/src/server/management/oauth-account-routes.ts +90 -8
- package/src/server/management/provider-capability-config.ts +48 -0
- package/src/server/management/provider-routes.ts +836 -159
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/route-registry.ts +332 -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 +289 -33
- 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 +126 -23
- package/src/server/management-auth.ts +220 -5
- package/src/server/passive-route-linker.ts +66 -0
- package/src/server/port-reclaim.ts +1 -1
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +161 -10
- package/src/server/readiness.ts +99 -0
- package/src/server/relay-eager.ts +221 -54
- package/src/server/relay.ts +479 -87
- package/src/server/request-decompress.ts +113 -6
- package/src/server/request-log-conversation.ts +51 -0
- package/src/server/request-log.ts +298 -31
- 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/codex-auth-error.ts +55 -0
- package/src/server/responses/collaboration.ts +239 -36
- package/src/server/responses/combo-stream-preflight.ts +178 -0
- package/src/server/responses/compact.ts +444 -72
- package/src/server/responses/core.ts +3579 -436
- package/src/server/responses/empty-completion-guard.ts +333 -0
- package/src/server/responses/encrypted-payload.ts +62 -39
- package/src/server/responses/fetch-helpers.ts +89 -99
- package/src/server/responses/input-admission.ts +187 -0
- package/src/server/responses/pacing-overload.ts +13 -0
- package/src/server/responses/passthrough-error.ts +33 -9
- package/src/server/responses/policy-fallback.ts +178 -0
- package/src/server/responses/responses-field-backfill.ts +343 -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 +381 -0
- package/src/server/responses-custom-tool-repair.ts +363 -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 +363 -0
- package/src/server/responses-tool-search-repair.ts +267 -0
- package/src/server/responses-undeclared-tool-guard.ts +376 -0
- package/src/server/responses.ts +18 -2
- package/src/server/search.ts +78 -13
- package/src/server/sse-frame-buffer.ts +319 -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 +53 -40
- package/src/service-manager-probe.ts +892 -0
- package/src/service.ts +1153 -94
- package/src/sidecar/auth.ts +92 -0
- package/src/sidecar/candidates.ts +83 -0
- package/src/storage/cleanup.ts +2 -2
- package/src/storage/policy-job.ts +14 -4
- package/src/storage/policy.ts +88 -23
- 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 +911 -0
- package/src/types/provider.ts +614 -0
- package/src/types/request.ts +397 -0
- package/src/types/tools.ts +236 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +107 -1237
- package/src/update/index.ts +37 -23
- package/src/update/job.ts +445 -68
- 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 +266 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +287 -16
- package/src/usage/log.ts +666 -50
- package/src/usage/summary.ts +388 -17
- 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 +27 -16
- 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 +119 -0
- package/src/web-search/executor.ts +21 -6
- package/src/web-search/gemini-executor.ts +141 -0
- package/src/web-search/index.ts +150 -15
- package/src/web-search/loop.ts +284 -54
- 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,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
effectiveCodexAuthAccountId,
|
|
4
|
+
fetchMainAccountInfoSnapshot,
|
|
5
|
+
listCodexAuthAccountsSnapshot,
|
|
6
|
+
withSparkVisibility,
|
|
7
|
+
} from "../codex/auth-api";
|
|
8
|
+
import type { StoredAccountQuota } from "../codex/quota";
|
|
9
|
+
import { isMainAccountIdentityGenerationLive } from "../codex/main-account-cache";
|
|
2
10
|
import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
|
|
11
|
+
import { codexPlanKey } from "../codex/plan";
|
|
3
12
|
import { resolveEnvValue } from "../config";
|
|
4
13
|
import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth";
|
|
5
14
|
import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store";
|
|
6
15
|
import { antigravityUserAgent } from "../adapters/client-fingerprint";
|
|
7
|
-
import {
|
|
16
|
+
import { apiKeyPoolEntryId } from "./api-keys";
|
|
17
|
+
import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport";
|
|
18
|
+
import { getProviderRegistryEntry, providerCodexAccountMode, registryEntryForProviderDestination } from "./registry";
|
|
8
19
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
9
20
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers";
|
|
10
21
|
import {
|
|
@@ -12,16 +23,57 @@ import {
|
|
|
12
23
|
sweepExpiredOnWrite,
|
|
13
24
|
type GenerationContext,
|
|
14
25
|
} from "../lib/state-store-sweeper";
|
|
26
|
+
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
27
|
+
import {
|
|
28
|
+
aggregateCodexPoolCapacity,
|
|
29
|
+
CODEX_CAPACITY_MAX_QUOTA_AGE_MS,
|
|
30
|
+
type CodexCapacityAggregation,
|
|
31
|
+
type CodexCapacityQuota,
|
|
32
|
+
} from "./codex-capacity";
|
|
15
33
|
|
|
16
34
|
/** Match oauth/index REFRESH_SKEW_MS — use stored access without refresh when still fresh. */
|
|
17
35
|
const ACCOUNT_TOKEN_SKEW_MS = 60_000;
|
|
18
36
|
|
|
19
37
|
const CACHE_TTL_MS = 5 * 60_000;
|
|
20
38
|
const REQUEST_TIMEOUT_MS = 8_000;
|
|
39
|
+
/** Successful provider quota payloads are small; reject oversized or stalled JSON before parsing. */
|
|
40
|
+
export const QUOTA_RESPONSE_MAX_BYTES = 512 * 1024;
|
|
21
41
|
const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
|
|
22
42
|
const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`;
|
|
43
|
+
const COMMAND_CODE_BASE_URL = "https://api.commandcode.ai";
|
|
44
|
+
const COMMAND_CODE_WHOAMI_URL = `${COMMAND_CODE_BASE_URL}/alpha/whoami`;
|
|
45
|
+
const COMMAND_CODE_CREDITS_URL = `${COMMAND_CODE_BASE_URL}/alpha/billing/credits`;
|
|
46
|
+
const COMMAND_CODE_SUBSCRIPTIONS_URL = `${COMMAND_CODE_BASE_URL}/alpha/billing/subscriptions`;
|
|
47
|
+
const COMMAND_CODE_USAGE_URL = `${COMMAND_CODE_BASE_URL}/alpha/usage/summary`;
|
|
48
|
+
const A6API_BASE_URL = "https://api.a6api.com";
|
|
49
|
+
const OPENCODE_GO_BASE_URL = "https://opencode.ai/zen/go/v1";
|
|
50
|
+
const OPENCODE_GO_USAGE_URL = `${OPENCODE_GO_BASE_URL}/usage`;
|
|
51
|
+
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
|
52
|
+
const DEEPSEEK_BASE_URL = "https://api.deepseek.com";
|
|
53
|
+
const CLINE_BASE_URL = "https://api.cline.bot";
|
|
54
|
+
const ZAI_BASE_URL = "https://api.z.ai";
|
|
55
|
+
const ZAI_CN_BASE_URL = "https://open.bigmodel.cn";
|
|
56
|
+
const MINIMAX_REMAINS_URL = "https://www.minimax.io/v1/token_plan/remains";
|
|
57
|
+
const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1";
|
|
58
|
+
const VENICE_BASE_URL = "https://api.venice.ai/api/v1";
|
|
59
|
+
const SYNTHETIC_BASE_URL = "https://api.synthetic.new/v2";
|
|
60
|
+
const DEEPINFRA_BASE_URL = "https://api.deepinfra.com";
|
|
61
|
+
const NEURALWATT_BASE_URL = "https://api.neuralwatt.com/v1";
|
|
62
|
+
const XAI_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing";
|
|
63
|
+
const XAI_CREDITS_URL = `${XAI_BILLING_URL}?format=credits`;
|
|
23
64
|
/** Keep a failed probe's previous row at most this long before dropping it. */
|
|
24
|
-
const LAST_GOOD_MAX_AGE_MS =
|
|
65
|
+
const LAST_GOOD_MAX_AGE_MS = CODEX_CAPACITY_MAX_QUOTA_AGE_MS;
|
|
66
|
+
const nativeMainReportGenerations = new WeakMap<ProviderQuotaReport, number>();
|
|
67
|
+
let providerQuotaBeforePublishForTests: (() => void | Promise<void>) | null = null;
|
|
68
|
+
|
|
69
|
+
/** Test-only seam for identity/config invalidation after probes but before publication. */
|
|
70
|
+
export function setProviderQuotaBeforePublishForTests(
|
|
71
|
+
hook: (() => void | Promise<void>) | null,
|
|
72
|
+
): void {
|
|
73
|
+
providerQuotaBeforePublishForTests = hook;
|
|
74
|
+
}
|
|
75
|
+
const TERMINAL_QUOTA_FAILURE = Symbol("terminal-quota-failure");
|
|
76
|
+
type ProviderQuotaProbeResult = ProviderQuotaReport | null | typeof TERMINAL_QUOTA_FAILURE;
|
|
25
77
|
|
|
26
78
|
export interface ProviderQuotaWindow {
|
|
27
79
|
label: string;
|
|
@@ -29,6 +81,15 @@ export interface ProviderQuotaWindow {
|
|
|
29
81
|
resetAt?: number;
|
|
30
82
|
}
|
|
31
83
|
|
|
84
|
+
export interface ProviderQuotaCreditsUsd {
|
|
85
|
+
used: number;
|
|
86
|
+
limit: number;
|
|
87
|
+
remaining: number;
|
|
88
|
+
percent: number;
|
|
89
|
+
expiresAt?: number;
|
|
90
|
+
unlimited?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
32
93
|
export interface ProviderQuota {
|
|
33
94
|
fiveHourPercent?: number;
|
|
34
95
|
fiveHourResetAt?: number;
|
|
@@ -37,6 +98,7 @@ export interface ProviderQuota {
|
|
|
37
98
|
monthlyPercent?: number;
|
|
38
99
|
monthlyResetAt?: number;
|
|
39
100
|
customWindows?: ProviderQuotaWindow[];
|
|
101
|
+
creditsUsd?: ProviderQuotaCreditsUsd;
|
|
40
102
|
updatedAt: number;
|
|
41
103
|
}
|
|
42
104
|
|
|
@@ -47,6 +109,7 @@ export interface ProviderQuotaReport {
|
|
|
47
109
|
quota: ProviderQuota;
|
|
48
110
|
updatedAt: number;
|
|
49
111
|
reverseEngineered?: boolean;
|
|
112
|
+
aggregation?: CodexCapacityAggregation;
|
|
50
113
|
}
|
|
51
114
|
|
|
52
115
|
export interface ProviderQuotaResponse {
|
|
@@ -67,10 +130,120 @@ export function clearProviderQuotaCache(): void {
|
|
|
67
130
|
|
|
68
131
|
function cacheKey(config: OcxConfig): string {
|
|
69
132
|
const providers = Object.entries(config.providers)
|
|
70
|
-
.map(([name, provider]) =>
|
|
133
|
+
.map(([name, provider]) => {
|
|
134
|
+
const resolvedKey = typeof provider.apiKey === "string"
|
|
135
|
+
? resolveEnvValue(provider.apiKey)?.trim()
|
|
136
|
+
: undefined;
|
|
137
|
+
const activeKeyId = resolvedKey ? apiKeyPoolEntryId(resolvedKey) : "none";
|
|
138
|
+
return `${name}:${provider.adapter}:${provider.authMode ?? "key"}:${providerCodexAccountMode(name, provider) ?? "none"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}:${activeKeyId}`;
|
|
139
|
+
})
|
|
71
140
|
.sort()
|
|
72
141
|
.join("|");
|
|
73
|
-
return `${config.defaultProvider}|${
|
|
142
|
+
return `${config.defaultProvider}|${providers}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
type CodexAuthAccountsSnapshotPromise = ReturnType<typeof listCodexAuthAccountsSnapshot>;
|
|
146
|
+
|
|
147
|
+
function hasCodexPoolProvider(config: OcxConfig): boolean {
|
|
148
|
+
return Object.entries(config.providers).some(([name, provider]) => (
|
|
149
|
+
provider.disabled !== true
|
|
150
|
+
&& isBuiltInChatGptForwardProvider(name, provider)
|
|
151
|
+
&& providerCodexAccountMode(name, provider) !== "direct"
|
|
152
|
+
));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function quotaSignatureValue(quota: CodexCapacityQuota | null): unknown {
|
|
156
|
+
if (!quota) return null;
|
|
157
|
+
return {
|
|
158
|
+
fiveHourPercent: quota.fiveHourPercent,
|
|
159
|
+
fiveHourResetAt: quota.fiveHourResetAt,
|
|
160
|
+
weeklyPercent: quota.weeklyPercent,
|
|
161
|
+
weeklyResetAt: quota.weeklyResetAt,
|
|
162
|
+
monthlyPercent: quota.monthlyPercent,
|
|
163
|
+
monthlyResetAt: quota.monthlyResetAt,
|
|
164
|
+
updatedAt: quota.updatedAt,
|
|
165
|
+
customWindows: [...(quota.customWindows ?? [])]
|
|
166
|
+
.map(window => ({ label: window.label, percent: window.percent, resetAt: window.resetAt }))
|
|
167
|
+
.sort((a, b) => a.label.localeCompare(b.label)),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function providerQuotaFromCodexQuota(
|
|
172
|
+
quota: StoredAccountQuota | Omit<StoredAccountQuota, "updatedAt"> | null | undefined,
|
|
173
|
+
): CodexCapacityQuota | null {
|
|
174
|
+
if (!quota) return null;
|
|
175
|
+
// Every Codex-sourced provider report funnels through here — the pooled path via
|
|
176
|
+
// listCodexAuthAccountsSnapshot and the `direct` path via fetchMainAccountInfoSnapshot, which
|
|
177
|
+
// never touches the Codex Auth DTO. Applying the Spark preference at this one point is what
|
|
178
|
+
// stops the row surviving on /api/provider-quotas after the operator switched it off.
|
|
179
|
+
quota = withSparkVisibility(quota ?? null) ?? quota;
|
|
180
|
+
return {
|
|
181
|
+
...(quota.shortPercent !== undefined ? { fiveHourPercent: quota.shortPercent } : {}),
|
|
182
|
+
...(quota.shortResetAt !== undefined ? { fiveHourResetAt: quota.shortResetAt } : {}),
|
|
183
|
+
...(quota.weeklyPercent !== undefined ? { weeklyPercent: quota.weeklyPercent } : {}),
|
|
184
|
+
...(quota.weeklyResetAt !== undefined ? { weeklyResetAt: quota.weeklyResetAt } : {}),
|
|
185
|
+
...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}),
|
|
186
|
+
...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}),
|
|
187
|
+
...(quota.customWindows !== undefined ? { customWindows: quota.customWindows } : {}),
|
|
188
|
+
updatedAt: "updatedAt" in quota ? quota.updatedAt : Date.now(),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Hash only presentation-relevant state; account ids and email addresses never enter the key. */
|
|
193
|
+
function cacheKeyWithAggregationState(
|
|
194
|
+
config: OcxConfig,
|
|
195
|
+
prefetchedSnapshot?: CodexAuthAccountsSnapshotPromise,
|
|
196
|
+
): string | Promise<string> {
|
|
197
|
+
const base = cacheKey(config);
|
|
198
|
+
if (!hasCodexPoolProvider(config)) return base;
|
|
199
|
+
return (async () => {
|
|
200
|
+
try {
|
|
201
|
+
const activeId = effectiveCodexAuthAccountId(config);
|
|
202
|
+
const snapshot = await (prefetchedSnapshot ?? listCodexAuthAccountsSnapshot(config, false));
|
|
203
|
+
const rows = snapshot.accounts.map(account => ({
|
|
204
|
+
isMain: account.isMain,
|
|
205
|
+
active: account.id === activeId,
|
|
206
|
+
plan: codexPlanKey(account.plan) ?? null,
|
|
207
|
+
paused: account.paused,
|
|
208
|
+
needsReauth: account.needsReauth === true,
|
|
209
|
+
quota: quotaSignatureValue(providerQuotaFromCodexQuota(account.quota)),
|
|
210
|
+
}));
|
|
211
|
+
const canonicalRows = rows.map(row => JSON.stringify(row)).sort();
|
|
212
|
+
const digest = createHash("sha256").update(JSON.stringify(canonicalRows)).digest("hex").slice(0, 24);
|
|
213
|
+
return `${base}|codex-pool:${digest}`;
|
|
214
|
+
} catch {
|
|
215
|
+
return `${base}|codex-pool:unavailable`;
|
|
216
|
+
}
|
|
217
|
+
})();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function publicCapacityWindow(window: import("./codex-capacity").CodexCapacityWindowAggregation) {
|
|
221
|
+
const { totalWeight: _totalWeight, consumedWeight: _consumedWeight, remainingWeight: _remainingWeight, ...safe } = window;
|
|
222
|
+
return safe;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Management API metadata intentionally omits configured/weighted unit counts. */
|
|
226
|
+
function publicCapacityAggregation(
|
|
227
|
+
aggregation: CodexCapacityAggregation,
|
|
228
|
+
presentation: NonNullable<CodexCapacityAggregation["presentation"]>,
|
|
229
|
+
): CodexCapacityAggregation {
|
|
230
|
+
const safeCurrentAccount = presentation === "coverage-only" && aggregation.currentAccount
|
|
231
|
+
? { ...aggregation.currentAccount, quota: null }
|
|
232
|
+
: aggregation.currentAccount;
|
|
233
|
+
return {
|
|
234
|
+
...aggregation,
|
|
235
|
+
presentation,
|
|
236
|
+
...(safeCurrentAccount ? { currentAccount: safeCurrentAccount } : {}),
|
|
237
|
+
...(aggregation.fiveHour ? { fiveHour: publicCapacityWindow(aggregation.fiveHour) } : {}),
|
|
238
|
+
...(aggregation.weekly ? { weekly: publicCapacityWindow(aggregation.weekly) } : {}),
|
|
239
|
+
...(aggregation.monthly ? { monthly: publicCapacityWindow(aggregation.monthly) } : {}),
|
|
240
|
+
...(aggregation.customWindows ? {
|
|
241
|
+
customWindows: aggregation.customWindows.map(window => ({
|
|
242
|
+
label: window.label,
|
|
243
|
+
...publicCapacityWindow(window),
|
|
244
|
+
})),
|
|
245
|
+
} : {}),
|
|
246
|
+
};
|
|
74
247
|
}
|
|
75
248
|
|
|
76
249
|
function hasQuotaRows(quota: ProviderQuota | null | undefined): quota is ProviderQuota {
|
|
@@ -78,6 +251,8 @@ function hasQuotaRows(quota: ProviderQuota | null | undefined): quota is Provide
|
|
|
78
251
|
return typeof quota.fiveHourPercent === "number"
|
|
79
252
|
|| typeof quota.weeklyPercent === "number"
|
|
80
253
|
|| typeof quota.monthlyPercent === "number"
|
|
254
|
+
|| quota.creditsUsd?.unlimited === true
|
|
255
|
+
|| typeof quota.creditsUsd?.percent === "number"
|
|
81
256
|
|| !!quota.customWindows?.some(window => typeof window.percent === "number");
|
|
82
257
|
}
|
|
83
258
|
|
|
@@ -86,21 +261,27 @@ function providerLabel(providerId: string): string {
|
|
|
86
261
|
}
|
|
87
262
|
|
|
88
263
|
function normalizeResetAt(value: unknown): number | undefined {
|
|
89
|
-
if (typeof value === "number" && Number.isFinite(value)) return value
|
|
264
|
+
if (typeof value === "number" && Number.isFinite(value)) return epochMillis(value);
|
|
90
265
|
if (typeof value === "string" && value.trim()) {
|
|
91
266
|
const trimmed = value.trim();
|
|
92
267
|
// Cursor Connect RPC returns billingCycleEnd as a unix-ms decimal string ("1771077734000").
|
|
93
268
|
// Date.parse treats that as invalid; numeric epoch strings must be handled explicitly.
|
|
94
|
-
if (
|
|
269
|
+
if (/^[+-]?\d+(\.\d+)?$/.test(trimmed)) {
|
|
95
270
|
const numeric = Number(trimmed);
|
|
96
|
-
|
|
271
|
+
return epochMillis(numeric);
|
|
97
272
|
}
|
|
98
273
|
const parsed = Date.parse(trimmed);
|
|
99
|
-
return Number.isFinite(parsed) ? parsed : undefined;
|
|
274
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
100
275
|
}
|
|
101
276
|
return undefined;
|
|
102
277
|
}
|
|
103
278
|
|
|
279
|
+
/** Unix 0 / negative values are sentinels, not reset clocks (Command Code fiveHour.resetAt: 0). */
|
|
280
|
+
function epochMillis(value: number): number | undefined {
|
|
281
|
+
if (!Number.isFinite(value) || value <= 0) return undefined;
|
|
282
|
+
return value > 10_000_000_000 ? value : value * 1000;
|
|
283
|
+
}
|
|
284
|
+
|
|
104
285
|
function toFiniteNumber(value: unknown): number | undefined {
|
|
105
286
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
106
287
|
if (typeof value === "string" && value.trim()) {
|
|
@@ -119,11 +300,799 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|
|
119
300
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
120
301
|
}
|
|
121
302
|
|
|
303
|
+
const QUOTA_JSON_READ_FAILURE = Symbol("quota-json-read-failure");
|
|
304
|
+
|
|
305
|
+
async function readQuotaJson(
|
|
306
|
+
response: Response,
|
|
307
|
+
timeoutMs = REQUEST_TIMEOUT_MS,
|
|
308
|
+
): Promise<unknown | typeof QUOTA_JSON_READ_FAILURE> {
|
|
309
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
310
|
+
if (Number.isFinite(declaredLength) && declaredLength > QUOTA_RESPONSE_MAX_BYTES) {
|
|
311
|
+
try {
|
|
312
|
+
void response.body?.cancel(
|
|
313
|
+
new DOMException("Provider quota response is too large", "QuotaExceededError"),
|
|
314
|
+
).catch(() => undefined);
|
|
315
|
+
} catch {
|
|
316
|
+
// Best-effort cancellation only.
|
|
317
|
+
}
|
|
318
|
+
return QUOTA_JSON_READ_FAILURE;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const bounded = await readBoundedResponseBody(response, {
|
|
323
|
+
maxBytes: QUOTA_RESPONSE_MAX_BYTES,
|
|
324
|
+
totalTimeoutMs: timeoutMs,
|
|
325
|
+
inactivityTimeoutMs: timeoutMs,
|
|
326
|
+
});
|
|
327
|
+
if (bounded.oversized || bounded.truncated || !bounded.displaySafe) return QUOTA_JSON_READ_FAILURE;
|
|
328
|
+
return JSON.parse(bounded.text) as unknown;
|
|
329
|
+
} catch {
|
|
330
|
+
return QUOTA_JSON_READ_FAILURE;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Test-only access to the quota reader's deadline and cancellation contract. */
|
|
335
|
+
export async function readProviderQuotaJsonForTests(response: Response, timeoutMs: number): Promise<unknown> {
|
|
336
|
+
const result = await readQuotaJson(response, timeoutMs);
|
|
337
|
+
return result === QUOTA_JSON_READ_FAILURE ? null : result;
|
|
338
|
+
}
|
|
339
|
+
|
|
122
340
|
function isBuiltInChatGptForwardProvider(name: string, provider: OcxProviderConfig): boolean {
|
|
123
341
|
return name === OPENAI_CODEX_PROVIDER_ID && isCanonicalOpenAiForwardProvider(provider);
|
|
124
342
|
}
|
|
125
343
|
|
|
126
|
-
function
|
|
344
|
+
function isCanonicalA6apiBaseUrl(baseUrl: string): boolean {
|
|
345
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
346
|
+
return normalized === A6API_BASE_URL || normalized === `${A6API_BASE_URL}/v1`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function isCanonicalOpenCodeGoBaseUrl(baseUrl: string): boolean {
|
|
350
|
+
return normalizedBaseUrl(baseUrl) === OPENCODE_GO_BASE_URL;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function isCanonicalOpenRouterBaseUrl(baseUrl: string): boolean {
|
|
354
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
355
|
+
return normalized === OPENROUTER_BASE_URL;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function isCanonicalDeepSeekBaseUrl(baseUrl: string): boolean {
|
|
359
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
360
|
+
return normalized === DEEPSEEK_BASE_URL || normalized === `${DEEPSEEK_BASE_URL}/v1`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isCanonicalClineBaseUrl(baseUrl: string): boolean {
|
|
364
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
365
|
+
return normalized === CLINE_BASE_URL || normalized === `${CLINE_BASE_URL}/api/v1`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function isCanonicalZaiBaseUrl(baseUrl: string): boolean {
|
|
369
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
370
|
+
return normalized === ZAI_BASE_URL
|
|
371
|
+
|| normalized === `${ZAI_BASE_URL}/api/coding/paas/v4`
|
|
372
|
+
|| normalized === ZAI_CN_BASE_URL
|
|
373
|
+
|| normalized === `${ZAI_CN_BASE_URL}/api/coding/paas/v4`
|
|
374
|
+
// BigModel serves the same GLM Coding Plan on the OpenAI Responses wire at /api/v1.
|
|
375
|
+
|| normalized === `${ZAI_CN_BASE_URL}/api/v1`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function isCanonicalMinimaxBaseUrl(baseUrl: string): boolean {
|
|
379
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
380
|
+
return normalized === "https://api.minimax.io/v1" || normalized === "https://api.minimaxi.com/v1";
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function isCanonicalMoonshotBaseUrl(baseUrl: string): boolean {
|
|
384
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
385
|
+
return normalized === MOONSHOT_BASE_URL || normalized === "https://api.moonshot.cn/v1";
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function isCanonicalVeniceBaseUrl(baseUrl: string): boolean {
|
|
389
|
+
return normalizedBaseUrl(baseUrl) === VENICE_BASE_URL;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function isCanonicalSyntheticBaseUrl(baseUrl: string): boolean {
|
|
393
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
394
|
+
return normalized === SYNTHETIC_BASE_URL || normalized === "https://api.synthetic.new/openai/v1";
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function isCanonicalDeepInfraBaseUrl(baseUrl: string): boolean {
|
|
398
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
399
|
+
return normalized === DEEPINFRA_BASE_URL || normalized === `${DEEPINFRA_BASE_URL}/v1/openai`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function isCanonicalNeuralwattBaseUrl(baseUrl: string): boolean {
|
|
403
|
+
return normalizedBaseUrl(baseUrl) === NEURALWATT_BASE_URL;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function a6apiPayload(value: unknown): Record<string, unknown> | null {
|
|
407
|
+
const body = asRecord(value);
|
|
408
|
+
return asRecord(body?.data) ?? body;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function firstFinite(record: Record<string, unknown> | null, names: string[]): number | undefined {
|
|
412
|
+
if (!record) return undefined;
|
|
413
|
+
for (const name of names) {
|
|
414
|
+
const value = toFiniteNumber(record[name]);
|
|
415
|
+
if (value !== undefined) return value;
|
|
416
|
+
}
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
421
|
+
// Never send a configured API key to a lookalike host or through a redirect.
|
|
422
|
+
if (!isCanonicalA6apiBaseUrl(config.baseUrl)) return null;
|
|
423
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
424
|
+
if (!apiKey) return null;
|
|
425
|
+
const headers = { Accept: "application/json", Authorization: `Bearer ${apiKey}` } as const;
|
|
426
|
+
const [subscriptionResponse, tokenResponse] = await Promise.all([
|
|
427
|
+
fetch(`${A6API_BASE_URL}/dashboard/billing/subscription`, {
|
|
428
|
+
headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
429
|
+
}),
|
|
430
|
+
fetch(`${A6API_BASE_URL}/api/usage/token/`, {
|
|
431
|
+
headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
432
|
+
}),
|
|
433
|
+
]);
|
|
434
|
+
if (!subscriptionResponse.ok || !tokenResponse.ok) {
|
|
435
|
+
const statuses = [subscriptionResponse.status, tokenResponse.status];
|
|
436
|
+
// 408/429 are transient (timeout/throttle), not invalid-account signals: keep the
|
|
437
|
+
// last-good row like 5xx/network failures. 401/403 (bad key) and 404 (contract change)
|
|
438
|
+
// stay terminal.
|
|
439
|
+
return statuses.some(status => status >= 400 && status < 500 && status !== 429 && status !== 408)
|
|
440
|
+
? TERMINAL_QUOTA_FAILURE
|
|
441
|
+
: null;
|
|
442
|
+
}
|
|
443
|
+
const [subscriptionBody, tokenBody] = await Promise.all([
|
|
444
|
+
readQuotaJson(subscriptionResponse),
|
|
445
|
+
readQuotaJson(tokenResponse),
|
|
446
|
+
]);
|
|
447
|
+
if (subscriptionBody === QUOTA_JSON_READ_FAILURE || tokenBody === QUOTA_JSON_READ_FAILURE) return null;
|
|
448
|
+
const subscription = a6apiPayload(subscriptionBody);
|
|
449
|
+
const token = a6apiPayload(tokenBody);
|
|
450
|
+
const unlimited = token?.unlimited_quota === true
|
|
451
|
+
|| token?.unlimited_quota === 1
|
|
452
|
+
|| token?.unlimited_quota === "true";
|
|
453
|
+
const normalizedExpiry = normalizeResetAt(token?.expires_at);
|
|
454
|
+
const expiry = normalizedExpiry && normalizedExpiry > 0
|
|
455
|
+
? { expiresAt: normalizedExpiry }
|
|
456
|
+
: {};
|
|
457
|
+
if (unlimited) {
|
|
458
|
+
return report(provider, "a6api:billing", {
|
|
459
|
+
creditsUsd: {
|
|
460
|
+
used: 0,
|
|
461
|
+
limit: 0,
|
|
462
|
+
remaining: 0,
|
|
463
|
+
percent: 0,
|
|
464
|
+
unlimited: true,
|
|
465
|
+
...expiry,
|
|
466
|
+
},
|
|
467
|
+
customWindows: [{ label: "Unlimited API credits", percent: 0 }],
|
|
468
|
+
updatedAt: Date.now(),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
const limitUsd = firstFinite(subscription, ["hard_limit_usd"]);
|
|
472
|
+
const grantedUnits = firstFinite(token, ["total_granted"]);
|
|
473
|
+
const usedUnits = firstFinite(token, ["total_used"]);
|
|
474
|
+
const availableUnits = firstFinite(token, ["total_available"]);
|
|
475
|
+
const reconciledUnits = usedUnits !== undefined && availableUnits !== undefined
|
|
476
|
+
? usedUnits + availableUnits
|
|
477
|
+
: undefined;
|
|
478
|
+
const reconciliationTolerance = grantedUnits !== undefined
|
|
479
|
+
? Math.abs(grantedUnits) * 1e-9
|
|
480
|
+
: 0;
|
|
481
|
+
if (limitUsd === undefined || grantedUnits === undefined || usedUnits === undefined
|
|
482
|
+
|| availableUnits === undefined || limitUsd <= 0 || grantedUnits <= 0
|
|
483
|
+
|| usedUnits < 0 || availableUnits < 0
|
|
484
|
+
|| reconciledUnits === undefined
|
|
485
|
+
|| Math.abs(reconciledUnits - grantedUnits) > reconciliationTolerance) return TERMINAL_QUOTA_FAILURE;
|
|
486
|
+
const usdPerUnit = limitUsd / grantedUnits;
|
|
487
|
+
const usedUsd = usedUnits * usdPerUnit;
|
|
488
|
+
const remainingUsd = Math.max(0, availableUnits * usdPerUnit);
|
|
489
|
+
const percent = normalizePercent((usedUsd / limitUsd) * 100);
|
|
490
|
+
if (percent === undefined) return TERMINAL_QUOTA_FAILURE;
|
|
491
|
+
const label = `API credits ($${remainingUsd.toFixed(2)} of $${limitUsd.toFixed(2)} remaining)`;
|
|
492
|
+
return report(provider, "a6api:billing", {
|
|
493
|
+
creditsUsd: {
|
|
494
|
+
used: usedUsd,
|
|
495
|
+
limit: limitUsd,
|
|
496
|
+
remaining: remainingUsd,
|
|
497
|
+
percent,
|
|
498
|
+
...expiry,
|
|
499
|
+
},
|
|
500
|
+
customWindows: [{ label, percent }],
|
|
501
|
+
updatedAt: Date.now(),
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function parseOpenCodeGoUsageWindow(value: unknown): { percent: number; resetAt?: number } | null {
|
|
506
|
+
const row = asRecord(value);
|
|
507
|
+
if (!row) return null;
|
|
508
|
+
const percent = normalizePercent(row.percent);
|
|
509
|
+
if (percent === undefined) return null;
|
|
510
|
+
const resetAt = normalizeResetAt(row.resetsAt);
|
|
511
|
+
return { percent, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function fetchOpenCodeGoQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
515
|
+
// Never send a configured API key when the provider destination is not the built-in Go endpoint.
|
|
516
|
+
if (!isCanonicalOpenCodeGoBaseUrl(config.baseUrl)) return null;
|
|
517
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
518
|
+
if (!apiKey) return null;
|
|
519
|
+
const response = await fetch(OPENCODE_GO_USAGE_URL, {
|
|
520
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
521
|
+
redirect: "error",
|
|
522
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
523
|
+
});
|
|
524
|
+
if (!response.ok) {
|
|
525
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
526
|
+
? TERMINAL_QUOTA_FAILURE
|
|
527
|
+
: null;
|
|
528
|
+
}
|
|
529
|
+
const body = asRecord(await readQuotaJson(response));
|
|
530
|
+
const usage = asRecord(body?.usage);
|
|
531
|
+
if (!usage) return null;
|
|
532
|
+
const rolling = parseOpenCodeGoUsageWindow(usage.rolling);
|
|
533
|
+
const weekly = parseOpenCodeGoUsageWindow(usage.weekly);
|
|
534
|
+
const monthly = parseOpenCodeGoUsageWindow(usage.monthly);
|
|
535
|
+
const quota: ProviderQuota = {
|
|
536
|
+
...(rolling ? {
|
|
537
|
+
fiveHourPercent: rolling.percent,
|
|
538
|
+
...(rolling.resetAt !== undefined ? { fiveHourResetAt: rolling.resetAt } : {}),
|
|
539
|
+
} : {}),
|
|
540
|
+
...(weekly ? {
|
|
541
|
+
weeklyPercent: weekly.percent,
|
|
542
|
+
...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}),
|
|
543
|
+
} : {}),
|
|
544
|
+
...(monthly ? {
|
|
545
|
+
monthlyPercent: monthly.percent,
|
|
546
|
+
...(monthly.resetAt !== undefined ? { monthlyResetAt: monthly.resetAt } : {}),
|
|
547
|
+
} : {}),
|
|
548
|
+
updatedAt: Date.now(),
|
|
549
|
+
};
|
|
550
|
+
return report(provider, "opencode-go:usage", quota);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* OpenRouter `GET /api/v1/key` — the key's own credit balance and optional
|
|
555
|
+
* per-key spending cap. `limit` is the configured cap (absent = uncapped);
|
|
556
|
+
* `usage` is lifetime spend; `limit_remaining` is what is left of the cap.
|
|
557
|
+
* When no cap is set there is no hard limit to meter against, so no bar is
|
|
558
|
+
* produced — the provider falls back to its documented reference.
|
|
559
|
+
*/
|
|
560
|
+
async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
561
|
+
// Never send a configured API key to a lookalike host or through a redirect.
|
|
562
|
+
if (!isCanonicalOpenRouterBaseUrl(config.baseUrl)) return null;
|
|
563
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
564
|
+
if (!apiKey) return null;
|
|
565
|
+
const response = await fetch(`${OPENROUTER_BASE_URL}/key`, {
|
|
566
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
567
|
+
redirect: "error",
|
|
568
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
569
|
+
});
|
|
570
|
+
if (!response.ok) {
|
|
571
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
572
|
+
? TERMINAL_QUOTA_FAILURE
|
|
573
|
+
: null;
|
|
574
|
+
}
|
|
575
|
+
const body = asRecord(await readQuotaJson(response));
|
|
576
|
+
const data = asRecord(body?.data) ?? body;
|
|
577
|
+
if (!data) return null;
|
|
578
|
+
const limit = toFiniteNumber(data.limit);
|
|
579
|
+
const limitRemaining = toFiniteNumber(data.limit_remaining);
|
|
580
|
+
const usage = toFiniteNumber(data.usage);
|
|
581
|
+
// A successful no-cap response is a DELIBERATE change, not a transient
|
|
582
|
+
// failure: the old capped row must be dropped, not preserved as last-good.
|
|
583
|
+
if (limit === undefined || limit <= 0) return TERMINAL_QUOTA_FAILURE;
|
|
584
|
+
// Prefer the authoritative remaining-cap value when present: `usage` is
|
|
585
|
+
// lifetime accumulated spend and overstates a reset or re-capped key.
|
|
586
|
+
const used = limitRemaining !== undefined
|
|
587
|
+
? Math.max(0, limit - limitRemaining)
|
|
588
|
+
: usage !== undefined && usage >= 0 ? usage : undefined;
|
|
589
|
+
if (used === undefined) return null;
|
|
590
|
+
const percent = normalizePercent((used / limit) * 100);
|
|
591
|
+
if (percent === undefined) return null;
|
|
592
|
+
const remaining = Math.max(0, limit - used);
|
|
593
|
+
const label = `API credits ($${remaining.toFixed(2)} of $${limit.toFixed(2)} remaining)`;
|
|
594
|
+
return report(provider, "openrouter:key-info", {
|
|
595
|
+
customWindows: [{ label, percent }],
|
|
596
|
+
updatedAt: Date.now(),
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* DeepSeek `GET /user/balance` — the account's granted + topped-up credit
|
|
602
|
+
* balance. The payload places `total_balance` / `granted_balance` inside
|
|
603
|
+
* entries of `balance_infos` (one row per currency); the row for the account's
|
|
604
|
+
* currency is selected by preference. `granted_balance` is a CURRENT balance
|
|
605
|
+
* component, not the original grant ceiling, so no consumed percentage is
|
|
606
|
+
* fabricated — the balance is reported as a balance-only window.
|
|
607
|
+
*/
|
|
608
|
+
async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
609
|
+
if (!isCanonicalDeepSeekBaseUrl(config.baseUrl)) return null;
|
|
610
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
611
|
+
if (!apiKey) return null;
|
|
612
|
+
const response = await fetch(`${DEEPSEEK_BASE_URL}/user/balance`, {
|
|
613
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
614
|
+
redirect: "error",
|
|
615
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
616
|
+
});
|
|
617
|
+
if (!response.ok) {
|
|
618
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
619
|
+
? TERMINAL_QUOTA_FAILURE
|
|
620
|
+
: null;
|
|
621
|
+
}
|
|
622
|
+
const body = asRecord(await readQuotaJson(response));
|
|
623
|
+
// The payload nests balances under `balance_infos` rows keyed by currency;
|
|
624
|
+
// prefer a USD row, then CNY, then the first row that parses.
|
|
625
|
+
const infos = Array.isArray(body?.balance_infos) ? body.balance_infos as unknown[] : null;
|
|
626
|
+
const rows = infos
|
|
627
|
+
? infos.map((raw): Record<string, unknown> | null => asRecord(raw)).filter((r): r is Record<string, unknown> => r !== null)
|
|
628
|
+
: [];
|
|
629
|
+
const pick = (currency: string): Record<string, unknown> | null =>
|
|
630
|
+
rows.find(row => String(row.currency ?? "").toUpperCase() === currency) ?? null;
|
|
631
|
+
const preferred = pick("USD") ?? pick("CNY") ?? rows[0] ?? null;
|
|
632
|
+
if (!preferred) return null;
|
|
633
|
+
const totalBalance = toFiniteNumber(preferred.total_balance);
|
|
634
|
+
const grantedBalance = toFiniteNumber(preferred.granted_balance);
|
|
635
|
+
const toppedUp = toFiniteNumber(preferred.topped_up_balance);
|
|
636
|
+
const balance = totalBalance ?? grantedBalance ?? toppedUp;
|
|
637
|
+
if (balance === undefined || balance < 0) return null;
|
|
638
|
+
const label = grantedBalance !== undefined && grantedBalance > 0
|
|
639
|
+
? `API balance ($${balance.toFixed(2)} total, $${grantedBalance.toFixed(2)} granted)`
|
|
640
|
+
: `API balance ($${balance.toFixed(2)})`;
|
|
641
|
+
return report(provider, "deepseek:balance", {
|
|
642
|
+
customWindows: [{ label, percent: 0 }],
|
|
643
|
+
updatedAt: Date.now(),
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* ClinePass `GET /api/v1/users/me/plan/usage-limits` — the subscription's
|
|
649
|
+
* rolling five-hour, weekly, and monthly utilization, matching the existing
|
|
650
|
+
* ProviderQuota windows directly. The endpoint 404s (or returns a null plan)
|
|
651
|
+
* for accounts without an active ClinePass, which is a no-report, not an error.
|
|
652
|
+
*/
|
|
653
|
+
async function fetchClineQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
654
|
+
if (!isCanonicalClineBaseUrl(config.baseUrl)) return null;
|
|
655
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
656
|
+
if (!apiKey) return null;
|
|
657
|
+
const response = await fetch(`${CLINE_BASE_URL}/api/v1/users/me/plan/usage-limits`, {
|
|
658
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
659
|
+
redirect: "error",
|
|
660
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
661
|
+
});
|
|
662
|
+
if (!response.ok) {
|
|
663
|
+
// 404 = no active plan; a plain "no plan" is a no-report, everything else
|
|
664
|
+
// 4xx (except 408/429) is a credential/contract problem.
|
|
665
|
+
if (response.status === 404) return null;
|
|
666
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
667
|
+
? TERMINAL_QUOTA_FAILURE
|
|
668
|
+
: null;
|
|
669
|
+
}
|
|
670
|
+
const body = asRecord(await readQuotaJson(response));
|
|
671
|
+
const data = asRecord(body?.data) ?? body;
|
|
672
|
+
const limits = Array.isArray(data?.limits) ? data.limits : null;
|
|
673
|
+
if (!limits) return null;
|
|
674
|
+
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
675
|
+
let windows = 0;
|
|
676
|
+
for (const raw of limits) {
|
|
677
|
+
const row = asRecord(raw);
|
|
678
|
+
if (!row) continue;
|
|
679
|
+
const percent = normalizePercent(row.percentUsed);
|
|
680
|
+
if (percent === undefined) continue;
|
|
681
|
+
const resetAt = normalizeResetAt(row.resetsAt);
|
|
682
|
+
if (row.type === "five_hour") {
|
|
683
|
+
quota.fiveHourPercent = percent;
|
|
684
|
+
if (resetAt !== undefined) quota.fiveHourResetAt = resetAt;
|
|
685
|
+
windows += 1;
|
|
686
|
+
} else if (row.type === "weekly") {
|
|
687
|
+
quota.weeklyPercent = percent;
|
|
688
|
+
if (resetAt !== undefined) quota.weeklyResetAt = resetAt;
|
|
689
|
+
windows += 1;
|
|
690
|
+
} else if (row.type === "monthly") {
|
|
691
|
+
quota.monthlyPercent = percent;
|
|
692
|
+
if (resetAt !== undefined) quota.monthlyResetAt = resetAt;
|
|
693
|
+
windows += 1;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return windows > 0 ? report(provider, "cline:plan-usage-limits", quota) : null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Z.AI GLM Coding Plan `GET /api/monitor/usage/quota/limit` — the coding-plan
|
|
701
|
+
* limits arrive as a `limits` array of `TOKENS_LIMIT` (newer plans call the
|
|
702
|
+
* same rows `CREDIT_LIMIT`) and `TIME_LIMIT` rows. `TOKENS_LIMIT`/`CREDIT_LIMIT`
|
|
703
|
+
* rows carry the window length as `unit`/`number`: unit 3 is hours (number 5 →
|
|
704
|
+
* the rolling five-hour window), unit 6 is weeks (number 1 → the weekly
|
|
705
|
+
* window). `TIME_LIMIT` rows are the monthly MCP tool budget (Web Search / Web
|
|
706
|
+
* Reader / Zread). Every row's `percentage` is the consumed share (falling
|
|
707
|
+
* back to `currentValue`/`usage` when absent) and `nextResetTime` (unix ms)
|
|
708
|
+
* the window reset.
|
|
709
|
+
*/
|
|
710
|
+
export function parseZaiQuotaLimits(data: Record<string, unknown> | null): ProviderQuota | null {
|
|
711
|
+
const limits = Array.isArray(data?.limits) ? data.limits as unknown[] : null;
|
|
712
|
+
if (!limits) return null;
|
|
713
|
+
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
714
|
+
let windows = 0;
|
|
715
|
+
for (const raw of limits) {
|
|
716
|
+
const row = asRecord(raw);
|
|
717
|
+
if (!row) continue;
|
|
718
|
+
const resetAt = normalizeResetAt(row.nextResetTime);
|
|
719
|
+
let percent = normalizePercent(row.percentage);
|
|
720
|
+
if (percent === undefined) {
|
|
721
|
+
const used = toFiniteNumber(row.currentValue);
|
|
722
|
+
const total = toFiniteNumber(row.usage);
|
|
723
|
+
if (used !== undefined && total !== undefined && total > 0) {
|
|
724
|
+
percent = normalizePercent((used / total) * 100);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (percent === undefined) continue;
|
|
728
|
+
if (row.type === "TOKENS_LIMIT" || row.type === "CREDIT_LIMIT") {
|
|
729
|
+
const unit = toFiniteNumber(row.unit);
|
|
730
|
+
const number = toFiniteNumber(row.number);
|
|
731
|
+
if (unit === 3 && number === 5) {
|
|
732
|
+
quota.fiveHourPercent = percent;
|
|
733
|
+
if (resetAt !== undefined) quota.fiveHourResetAt = resetAt;
|
|
734
|
+
windows += 1;
|
|
735
|
+
} else if (unit === 6 && number === 1) {
|
|
736
|
+
quota.weeklyPercent = percent;
|
|
737
|
+
if (resetAt !== undefined) quota.weeklyResetAt = resetAt;
|
|
738
|
+
windows += 1;
|
|
739
|
+
}
|
|
740
|
+
} else if (row.type === "TIME_LIMIT") {
|
|
741
|
+
quota.monthlyPercent = percent;
|
|
742
|
+
if (resetAt !== undefined) quota.monthlyResetAt = resetAt;
|
|
743
|
+
windows += 1;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return windows > 0 ? quota : null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Legacy Z.AI payload shape: percent fields with window identifiers directly on
|
|
751
|
+
* the data object (optionally nested under `quota`). Kept as a fallback so
|
|
752
|
+
* older responses keep rendering when the `limits` array is absent.
|
|
753
|
+
*/
|
|
754
|
+
function parseZaiQuotaLegacyFields(data: Record<string, unknown> | null): ProviderQuota | null {
|
|
755
|
+
if (!data) return null;
|
|
756
|
+
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
757
|
+
let windows = 0;
|
|
758
|
+
const percentAt = (key: string): number | undefined => {
|
|
759
|
+
const value = normalizePercent(data[key]);
|
|
760
|
+
if (value !== undefined) return value;
|
|
761
|
+
const nested = asRecord(data.quota);
|
|
762
|
+
return nested ? normalizePercent(nested[key]) : undefined;
|
|
763
|
+
};
|
|
764
|
+
const fiveHour = percentAt("fiveHourPercent") ?? percentAt("fiveHourUsage") ?? percentAt("fiveHourUsed");
|
|
765
|
+
const weekly = percentAt("weeklyPercent") ?? percentAt("weeklyUsage") ?? percentAt("weeklyUsed");
|
|
766
|
+
const monthly = percentAt("monthlyPercent") ?? percentAt("mcpPercent") ?? percentAt("monthlyMCPUsage");
|
|
767
|
+
if (fiveHour !== undefined) {
|
|
768
|
+
quota.fiveHourPercent = fiveHour;
|
|
769
|
+
windows += 1;
|
|
770
|
+
}
|
|
771
|
+
if (weekly !== undefined) {
|
|
772
|
+
quota.weeklyPercent = weekly;
|
|
773
|
+
windows += 1;
|
|
774
|
+
}
|
|
775
|
+
if (monthly !== undefined) {
|
|
776
|
+
quota.monthlyPercent = monthly;
|
|
777
|
+
windows += 1;
|
|
778
|
+
}
|
|
779
|
+
return windows > 0 ? quota : null;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Fetches the Z.AI GLM Coding Plan quota — on whichever region the provider
|
|
784
|
+
* points at (api.z.ai or open.bigmodel.cn). Authenticates with the API key as
|
|
785
|
+
* a Bearer token per Z.AI's API reference. The `limits` array shape is
|
|
786
|
+
* preferred; older field-name payloads fall back to the legacy parser.
|
|
787
|
+
*/
|
|
788
|
+
async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
789
|
+
if (!isCanonicalZaiBaseUrl(config.baseUrl)) return null;
|
|
790
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
791
|
+
if (!apiKey) return null;
|
|
792
|
+
const normalized = normalizedBaseUrl(config.baseUrl);
|
|
793
|
+
const monitorHost = normalized === ZAI_BASE_URL || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4`
|
|
794
|
+
? ZAI_BASE_URL
|
|
795
|
+
: ZAI_CN_BASE_URL;
|
|
796
|
+
const response = await fetch(`${monitorHost}/api/monitor/usage/quota/limit`, {
|
|
797
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
798
|
+
redirect: "error",
|
|
799
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
800
|
+
});
|
|
801
|
+
if (!response.ok) {
|
|
802
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
803
|
+
? TERMINAL_QUOTA_FAILURE
|
|
804
|
+
: null;
|
|
805
|
+
}
|
|
806
|
+
const body = asRecord(await readQuotaJson(response));
|
|
807
|
+
if (!body || body.success === false) return null;
|
|
808
|
+
const data = asRecord(body.data) ?? body;
|
|
809
|
+
const quota = Array.isArray(data?.limits)
|
|
810
|
+
? parseZaiQuotaLimits(data)
|
|
811
|
+
: parseZaiQuotaLegacyFields(data);
|
|
812
|
+
return quota ? report(provider, "zai:quota-limit", quota) : null;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* MiniMax Token Plan `GET /v1/token_plan/remains` — the subscription's
|
|
817
|
+
* remaining quota as a countdown-time value (ms). The endpoint does not expose
|
|
818
|
+
* the plan's total duration, so no percentage is fabricated from a presumed
|
|
819
|
+
* window: the remaining time is reported as a duration-only window. When the
|
|
820
|
+
* API supplies a total (`total_time` / `plan_duration_ms`), a consumed share
|
|
821
|
+
* is derived from it. Region selects the host: `minimax` → www.minimax.io,
|
|
822
|
+
* `minimax-cn` → api.minimaxi.com.
|
|
823
|
+
*/
|
|
824
|
+
async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
825
|
+
if (!isCanonicalMinimaxBaseUrl(config.baseUrl)) return null;
|
|
826
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
827
|
+
if (!apiKey) return null;
|
|
828
|
+
const cnHost = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.minimaxi.com");
|
|
829
|
+
const remainsUrl = cnHost ? "https://api.minimaxi.com/v1/token_plan/remains" : MINIMAX_REMAINS_URL;
|
|
830
|
+
const response = await fetch(remainsUrl, {
|
|
831
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
832
|
+
redirect: "error",
|
|
833
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
834
|
+
});
|
|
835
|
+
if (!response.ok) {
|
|
836
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
837
|
+
? TERMINAL_QUOTA_FAILURE
|
|
838
|
+
: null;
|
|
839
|
+
}
|
|
840
|
+
const body = asRecord(await readQuotaJson(response));
|
|
841
|
+
if (!body || body.success === false) return null;
|
|
842
|
+
const data = asRecord(body.data) ?? body;
|
|
843
|
+
const remainsMs = toFiniteNumber(data.remains_time ?? data.remainsTime);
|
|
844
|
+
if (remainsMs === undefined || remainsMs < 0) return null;
|
|
845
|
+
const hours = Math.floor(remainsMs / 3_600_000);
|
|
846
|
+
const label = `Token Plan remaining (${hours}h)`;
|
|
847
|
+
// Only derive a consumed share when the API actually reports the plan total;
|
|
848
|
+
// a presumed window (e.g. 30 days) would fabricate utilization. A valid
|
|
849
|
+
// response that omits the total after a prior refresh had it is a DELIBERATE
|
|
850
|
+
// contract change — the old row must be dropped (terminal), not preserved as
|
|
851
|
+
// a transient last-good.
|
|
852
|
+
const totalMs = toFiniteNumber(data.total_time ?? data.plan_duration_ms ?? data.total_duration_ms);
|
|
853
|
+
if (totalMs === undefined || totalMs <= 0) return TERMINAL_QUOTA_FAILURE;
|
|
854
|
+
const consumed = Math.max(0, totalMs - remainsMs);
|
|
855
|
+
const percent = normalizePercent((consumed / totalMs) * 100);
|
|
856
|
+
if (percent === undefined) return null;
|
|
857
|
+
return report(provider, "minimax:token-plan-remains", {
|
|
858
|
+
customWindows: [{ label, percent }],
|
|
859
|
+
updatedAt: Date.now(),
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Moonshot/Kimi `GET /v1/users/me/balance` — the account's available balance
|
|
865
|
+
* (voucher + cash). Renders a single balance window against the sum of
|
|
866
|
+
* voucher + cash when positive (there is no per-window rate limit to meter).
|
|
867
|
+
*/
|
|
868
|
+
async function fetchMoonshotQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
869
|
+
if (!isCanonicalMoonshotBaseUrl(config.baseUrl)) return null;
|
|
870
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
871
|
+
if (!apiKey) return null;
|
|
872
|
+
const host = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.moonshot.cn") ? "https://api.moonshot.cn/v1" : MOONSHOT_BASE_URL;
|
|
873
|
+
const response = await fetch(`${host}/users/me/balance`, {
|
|
874
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
875
|
+
redirect: "error",
|
|
876
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
877
|
+
});
|
|
878
|
+
if (!response.ok) {
|
|
879
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
880
|
+
? TERMINAL_QUOTA_FAILURE
|
|
881
|
+
: null;
|
|
882
|
+
}
|
|
883
|
+
const body = asRecord(await readQuotaJson(response));
|
|
884
|
+
const data = asRecord(body?.data) ?? body;
|
|
885
|
+
if (!data) return null;
|
|
886
|
+
const available = toFiniteNumber(data.available_balance);
|
|
887
|
+
const voucher = toFiniteNumber(data.voucher_balance);
|
|
888
|
+
const cash = toFiniteNumber(data.cash_balance);
|
|
889
|
+
if (available === undefined || available < 0) return null;
|
|
890
|
+
// Moonshot exposes no per-window quota ceiling, only a balance — report it
|
|
891
|
+
// as a balance-only window (percent 0) rather than a fabricated utilization.
|
|
892
|
+
// Currency is host-scoped: China platform (api.moonshot.cn) bills in CNY;
|
|
893
|
+
// the international platform (api.moonshot.ai) bills in USD. Do not force
|
|
894
|
+
// either side into the other unit — the number is correct, only the unit
|
|
895
|
+
// must match the host.
|
|
896
|
+
const isChinaHost = host.startsWith("https://api.moonshot.cn");
|
|
897
|
+
const money = (n: number) => isChinaHost ? `¥${n.toFixed(2)}` : `$${n.toFixed(2)}`;
|
|
898
|
+
const unit = isChinaHost ? "CNY" : "USD";
|
|
899
|
+
const label = voucher !== undefined && cash !== undefined
|
|
900
|
+
? `Balance (${money(available)} ${unit} available, ${money(voucher)} voucher)`
|
|
901
|
+
: `Balance (${money(available)} ${unit} available)`;
|
|
902
|
+
return report(provider, "moonshot:balance", {
|
|
903
|
+
customWindows: [{ label, percent: 0 }],
|
|
904
|
+
updatedAt: Date.now(),
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Venice `GET /api/v1/billing/balance` — DIEM (native credits) or USD balance.
|
|
910
|
+
* Shows the remaining balance; epoch allocation progress when present.
|
|
911
|
+
*/
|
|
912
|
+
async function fetchVeniceQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
913
|
+
if (!isCanonicalVeniceBaseUrl(config.baseUrl)) return null;
|
|
914
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
915
|
+
if (!apiKey) return null;
|
|
916
|
+
const response = await fetch(`${VENICE_BASE_URL}/billing/balance`, {
|
|
917
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
918
|
+
redirect: "error",
|
|
919
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
920
|
+
});
|
|
921
|
+
if (!response.ok) {
|
|
922
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
923
|
+
? TERMINAL_QUOTA_FAILURE
|
|
924
|
+
: null;
|
|
925
|
+
}
|
|
926
|
+
const body = asRecord(await readQuotaJson(response));
|
|
927
|
+
const data = asRecord(body?.data) ?? body;
|
|
928
|
+
if (!data) return null;
|
|
929
|
+
const diemBalance = toFiniteNumber(data.balance);
|
|
930
|
+
const usdBalance = toFiniteNumber(data.balance_usd);
|
|
931
|
+
const epochUsed = toFiniteNumber(data.diem_epoch_used);
|
|
932
|
+
const epochAllocated = toFiniteNumber(data.diem_epoch_allocated);
|
|
933
|
+
if (diemBalance === undefined && usdBalance === undefined) return null;
|
|
934
|
+
const label = diemBalance !== undefined
|
|
935
|
+
? `DIEM balance (${Math.round(diemBalance)})`
|
|
936
|
+
: `USD balance ($${usdBalance?.toFixed(2) ?? "?"})`;
|
|
937
|
+
if (epochAllocated !== undefined && epochAllocated > 0 && epochUsed !== undefined) {
|
|
938
|
+
const percent = normalizePercent((epochUsed / epochAllocated) * 100);
|
|
939
|
+
if (percent === undefined) return null;
|
|
940
|
+
return report(provider, "venice:billing-balance", {
|
|
941
|
+
customWindows: [{ label, percent }],
|
|
942
|
+
updatedAt: Date.now(),
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
return report(provider, "venice:billing-balance", {
|
|
946
|
+
customWindows: [{ label, percent: 0 }],
|
|
947
|
+
updatedAt: Date.now(),
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Synthetic `GET /v2/quotas` — the known quota lanes (rolling 5-hour,
|
|
953
|
+
* weekly token, search-hourly) mapped onto the quota windows.
|
|
954
|
+
*/
|
|
955
|
+
async function fetchSyntheticQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
956
|
+
if (!isCanonicalSyntheticBaseUrl(config.baseUrl)) return null;
|
|
957
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
958
|
+
if (!apiKey) return null;
|
|
959
|
+
const response = await fetch(`${SYNTHETIC_BASE_URL}/quotas`, {
|
|
960
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
961
|
+
redirect: "error",
|
|
962
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
963
|
+
});
|
|
964
|
+
if (!response.ok) {
|
|
965
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
966
|
+
? TERMINAL_QUOTA_FAILURE
|
|
967
|
+
: null;
|
|
968
|
+
}
|
|
969
|
+
const body = asRecord(await readQuotaJson(response));
|
|
970
|
+
const data = asRecord(body?.data) ?? body;
|
|
971
|
+
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
972
|
+
let windows = 0;
|
|
973
|
+
const percentAt = (key: string): number | undefined => {
|
|
974
|
+
const value = normalizePercent(data?.[key]);
|
|
975
|
+
if (value !== undefined) return value;
|
|
976
|
+
const nested = asRecord(data?.quota) ?? asRecord(data?.quotas);
|
|
977
|
+
return nested ? normalizePercent(nested[key]) : undefined;
|
|
978
|
+
};
|
|
979
|
+
const fiveHour = percentAt("rollingFiveHourLimit");
|
|
980
|
+
const weekly = percentAt("weeklyTokenLimit");
|
|
981
|
+
if (fiveHour !== undefined) {
|
|
982
|
+
quota.fiveHourPercent = fiveHour;
|
|
983
|
+
windows += 1;
|
|
984
|
+
}
|
|
985
|
+
if (weekly !== undefined) {
|
|
986
|
+
quota.weeklyPercent = weekly;
|
|
987
|
+
windows += 1;
|
|
988
|
+
}
|
|
989
|
+
const search = asRecord(data?.search);
|
|
990
|
+
const searchHourly = search ? normalizePercent(search.hourly) : undefined;
|
|
991
|
+
if (searchHourly !== undefined) {
|
|
992
|
+
quota.customWindows = [...(quota.customWindows ?? []), { label: "Search hourly", percent: searchHourly }];
|
|
993
|
+
windows += 1;
|
|
994
|
+
}
|
|
995
|
+
return windows > 0 ? report(provider, "synthetic:quotas", quota) : null;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* DeepInfra `GET /payment/checklist?compute_owed=true` — prepaid balance,
|
|
1000
|
+
* recent spend, spending limit, and suspension state. Renders a balance
|
|
1001
|
+
* window (prepaid funds are a negative `stripe_balance` → positive available).
|
|
1002
|
+
*/
|
|
1003
|
+
async function fetchDeepInfraQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
1004
|
+
if (!isCanonicalDeepInfraBaseUrl(config.baseUrl)) return null;
|
|
1005
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
1006
|
+
if (!apiKey) return null;
|
|
1007
|
+
const response = await fetch(`${DEEPINFRA_BASE_URL}/payment/checklist?compute_owed=true`, {
|
|
1008
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
1009
|
+
redirect: "error",
|
|
1010
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1011
|
+
});
|
|
1012
|
+
if (!response.ok) {
|
|
1013
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
1014
|
+
? TERMINAL_QUOTA_FAILURE
|
|
1015
|
+
: null;
|
|
1016
|
+
}
|
|
1017
|
+
const body = asRecord(await readQuotaJson(response));
|
|
1018
|
+
const data = asRecord(body?.data) ?? body;
|
|
1019
|
+
if (!data) return null;
|
|
1020
|
+
const stripeBalance = toFiniteNumber(data.stripe_balance);
|
|
1021
|
+
const spendLimit = toFiniteNumber(data.spending_limit);
|
|
1022
|
+
const total = toFiniteNumber(data.total_amount_due);
|
|
1023
|
+
if (stripeBalance === undefined) return null;
|
|
1024
|
+
// Prepaid funds are negative; a positive value is money owed.
|
|
1025
|
+
const available = stripeBalance < 0 ? -stripeBalance : 0;
|
|
1026
|
+
if (spendLimit !== undefined && spendLimit > 0) {
|
|
1027
|
+
const spent = total !== undefined && total > 0 ? total : Math.max(0, spendLimit - available);
|
|
1028
|
+
const percent = normalizePercent((spent / spendLimit) * 100);
|
|
1029
|
+
if (percent === undefined) return null;
|
|
1030
|
+
return report(provider, "deepinfra:billing-checklist", {
|
|
1031
|
+
customWindows: [{ label: `Billing cycle spend ($${spent.toFixed(2)} of $${spendLimit.toFixed(2)})`, percent }],
|
|
1032
|
+
updatedAt: Date.now(),
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
return report(provider, "deepinfra:billing-checklist", {
|
|
1036
|
+
customWindows: [{ label: `Prepaid balance ($${available.toFixed(2)})`, percent: 0 }],
|
|
1037
|
+
updatedAt: Date.now(),
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Neuralwatt `GET /v1/quota` — subscription kWh usage (primary window) and
|
|
1043
|
+
* prepaid USD credit balance (secondary).
|
|
1044
|
+
*/
|
|
1045
|
+
async function fetchNeuralwattQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
1046
|
+
if (!isCanonicalNeuralwattBaseUrl(config.baseUrl)) return null;
|
|
1047
|
+
const apiKey = resolveEnvValue(config.apiKey)?.trim();
|
|
1048
|
+
if (!apiKey) return null;
|
|
1049
|
+
const response = await fetch(`${NEURALWATT_BASE_URL}/quota`, {
|
|
1050
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
1051
|
+
redirect: "error",
|
|
1052
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1053
|
+
});
|
|
1054
|
+
if (!response.ok) {
|
|
1055
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
1056
|
+
? TERMINAL_QUOTA_FAILURE
|
|
1057
|
+
: null;
|
|
1058
|
+
}
|
|
1059
|
+
const body = asRecord(await readQuotaJson(response));
|
|
1060
|
+
const data = asRecord(body?.data) ?? body;
|
|
1061
|
+
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
1062
|
+
let windows = 0;
|
|
1063
|
+
const subscription = asRecord(data?.subscription);
|
|
1064
|
+
const kwhUsed = subscription ? toFiniteNumber(subscription.kwh_used) : undefined;
|
|
1065
|
+
const kwhIncluded = subscription ? toFiniteNumber(subscription.kwh_included) : undefined;
|
|
1066
|
+
if (kwhUsed !== undefined && kwhIncluded !== undefined && kwhIncluded > 0) {
|
|
1067
|
+
const percent = normalizePercent((kwhUsed / kwhIncluded) * 100);
|
|
1068
|
+
if (percent !== undefined) {
|
|
1069
|
+
quota.fiveHourPercent = percent;
|
|
1070
|
+
const periodEnd = subscription ? normalizeResetAt(subscription.current_period_end) : undefined;
|
|
1071
|
+
if (periodEnd !== undefined) quota.fiveHourResetAt = periodEnd;
|
|
1072
|
+
windows += 1;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
const balance = asRecord(data?.balance);
|
|
1076
|
+
const totalCredits = balance ? toFiniteNumber(balance.total_credits_usd) : undefined;
|
|
1077
|
+
const remainingCredits = balance ? toFiniteNumber(balance.credits_remaining_usd) : undefined;
|
|
1078
|
+
if (totalCredits !== undefined && totalCredits > 0 && remainingCredits !== undefined) {
|
|
1079
|
+
// Utilization is CONSUMED credits, not the remaining share.
|
|
1080
|
+
const used = Math.max(0, totalCredits - remainingCredits);
|
|
1081
|
+
const percent = normalizePercent((used / totalCredits) * 100);
|
|
1082
|
+
if (percent !== undefined) {
|
|
1083
|
+
quota.customWindows = [...(quota.customWindows ?? []), { label: "Prepaid credits", percent }];
|
|
1084
|
+
windows += 1;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
return windows > 0 ? report(provider, "neuralwatt:quota", quota) : null;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function report(
|
|
1091
|
+
provider: string,
|
|
1092
|
+
source: string,
|
|
1093
|
+
quota: ProviderQuota,
|
|
1094
|
+
aggregation?: CodexCapacityAggregation,
|
|
1095
|
+
): ProviderQuotaReport | null {
|
|
127
1096
|
if (!hasQuotaRows(quota)) return null;
|
|
128
1097
|
return {
|
|
129
1098
|
provider,
|
|
@@ -131,27 +1100,95 @@ function report(provider: string, source: string, quota: ProviderQuota): Provide
|
|
|
131
1100
|
source,
|
|
132
1101
|
quota,
|
|
133
1102
|
updatedAt: quota.updatedAt,
|
|
1103
|
+
...(aggregation ? { aggregation } : {}),
|
|
134
1104
|
};
|
|
135
1105
|
}
|
|
136
1106
|
|
|
1107
|
+
function tagNativeMainReport(
|
|
1108
|
+
value: ProviderQuotaReport | null,
|
|
1109
|
+
generation: number,
|
|
1110
|
+
): ProviderQuotaReport | null {
|
|
1111
|
+
if (value) nativeMainReportGenerations.set(value, generation);
|
|
1112
|
+
return value;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolean {
|
|
1116
|
+
const generation = nativeMainReportGenerations.get(value);
|
|
1117
|
+
return generation === undefined || isMainAccountIdentityGenerationLive(generation);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
137
1120
|
async function fetchChatGptForwardQuota(
|
|
138
1121
|
config: OcxConfig,
|
|
139
1122
|
provider: string,
|
|
140
1123
|
providerConfig: OcxProviderConfig,
|
|
141
1124
|
forceRefresh: boolean,
|
|
1125
|
+
prefetchedSnapshot?: CodexAuthAccountsSnapshotPromise,
|
|
142
1126
|
): Promise<ProviderQuotaReport | null> {
|
|
143
1127
|
if (providerCodexAccountMode(provider, providerConfig) === "direct") {
|
|
144
|
-
const
|
|
145
|
-
const quota =
|
|
146
|
-
|
|
1128
|
+
const snapshot = await fetchMainAccountInfoSnapshot(forceRefresh);
|
|
1129
|
+
const quota = providerQuotaFromCodexQuota(snapshot.info.quota);
|
|
1130
|
+
if (quota) quota.updatedAt = Date.now();
|
|
1131
|
+
return quota
|
|
1132
|
+
? tagNativeMainReport(report(provider, "chatgpt:wham", quota), snapshot.mainIdentityGeneration)
|
|
1133
|
+
: null;
|
|
147
1134
|
}
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
1135
|
+
const snapshot = await (prefetchedSnapshot ?? listCodexAuthAccountsSnapshot(config, forceRefresh));
|
|
1136
|
+
const accounts = snapshot.accounts;
|
|
1137
|
+
const activeId = effectiveCodexAuthAccountId(config);
|
|
1138
|
+
const capacityAccounts = accounts.map(account => ({
|
|
1139
|
+
...account,
|
|
1140
|
+
active: account.id === activeId,
|
|
1141
|
+
quota: providerQuotaFromCodexQuota(account.quota),
|
|
1142
|
+
}));
|
|
1143
|
+
const active = capacityAccounts.find(account => account.active)
|
|
1144
|
+
?? capacityAccounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)
|
|
1145
|
+
?? capacityAccounts[0];
|
|
1146
|
+
const now = Date.now();
|
|
1147
|
+
const capacity = aggregateCodexPoolCapacity(capacityAccounts, now);
|
|
1148
|
+
if (capacity.aggregation && capacity.quota) {
|
|
1149
|
+
return tagNativeMainReport(
|
|
1150
|
+
report(
|
|
1151
|
+
provider,
|
|
1152
|
+
"chatgpt:wham",
|
|
1153
|
+
capacity.quota as ProviderQuota,
|
|
1154
|
+
publicCapacityAggregation(capacity.aggregation, "aggregate"),
|
|
1155
|
+
),
|
|
1156
|
+
snapshot.mainIdentityGeneration,
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
const activeUsable = !!active && !active.paused && active.needsReauth !== true;
|
|
1160
|
+
const quota = activeUsable && active?.quota
|
|
1161
|
+
? { ...active.quota, updatedAt: active.quota.updatedAt ?? Date.now() } as CodexCapacityQuota
|
|
1162
|
+
: null;
|
|
1163
|
+
const quotaFresh = !!quota
|
|
1164
|
+
&& Number.isFinite(quota.updatedAt)
|
|
1165
|
+
&& now - quota.updatedAt < CODEX_CAPACITY_MAX_QUOTA_AGE_MS;
|
|
1166
|
+
if (quota && quotaFresh) {
|
|
1167
|
+
const fallback = report(
|
|
1168
|
+
provider,
|
|
1169
|
+
"chatgpt:wham",
|
|
1170
|
+
quota as ProviderQuota,
|
|
1171
|
+
capacity.aggregation
|
|
1172
|
+
? publicCapacityAggregation(capacity.aggregation, "effective-account-fallback")
|
|
1173
|
+
: undefined,
|
|
1174
|
+
);
|
|
1175
|
+
return tagNativeMainReport(fallback, snapshot.mainIdentityGeneration);
|
|
1176
|
+
}
|
|
1177
|
+
if (capacity.aggregation) {
|
|
1178
|
+
const updatedAt = Date.now();
|
|
1179
|
+
return tagNativeMainReport(
|
|
1180
|
+
{
|
|
1181
|
+
provider,
|
|
1182
|
+
label: providerLabel(provider),
|
|
1183
|
+
source: "chatgpt:wham",
|
|
1184
|
+
quota: { updatedAt },
|
|
1185
|
+
updatedAt,
|
|
1186
|
+
aggregation: publicCapacityAggregation(capacity.aggregation, "coverage-only"),
|
|
1187
|
+
},
|
|
1188
|
+
snapshot.mainIdentityGeneration,
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
return null;
|
|
155
1192
|
}
|
|
156
1193
|
|
|
157
1194
|
function centsValue(value: unknown): number | undefined {
|
|
@@ -456,6 +1493,24 @@ function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number
|
|
|
456
1493
|
return { percent, resetAt };
|
|
457
1494
|
}
|
|
458
1495
|
|
|
1496
|
+
function parseClaudeLimit(value: unknown): { label: string; percent: number; resetAt?: number } | null {
|
|
1497
|
+
const rec = asRecord(value);
|
|
1498
|
+
if (!rec) return null;
|
|
1499
|
+
const percent = normalizePercent(rec.percent);
|
|
1500
|
+
if (percent === undefined) return null;
|
|
1501
|
+
const scope = asRecord(rec.scope);
|
|
1502
|
+
const model = asRecord(scope?.model);
|
|
1503
|
+
const rawLabel = String(model?.display_name ?? "").trim();
|
|
1504
|
+
if (!rawLabel) return null;
|
|
1505
|
+
const lowerLabel = rawLabel.toLowerCase();
|
|
1506
|
+
const label = lowerLabel.includes("fable") ? "Fable"
|
|
1507
|
+
: lowerLabel.includes("opus") ? "Opus"
|
|
1508
|
+
: lowerLabel.includes("sonnet") ? "Sonnet"
|
|
1509
|
+
: rawLabel;
|
|
1510
|
+
const resetAt = normalizeResetAt(rec.resets_at);
|
|
1511
|
+
return { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
1512
|
+
}
|
|
1513
|
+
|
|
459
1514
|
/** Claude's OAuth usage endpoint, probed with ONE account's own bearer token. */
|
|
460
1515
|
const anthropicUsageInflight = new Map<string, Promise<ProviderQuota | null>>();
|
|
461
1516
|
|
|
@@ -475,15 +1530,29 @@ async function fetchAnthropicUsageQuota(accessToken: string): Promise<ProviderQu
|
|
|
475
1530
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
476
1531
|
});
|
|
477
1532
|
if (!response.ok) return null;
|
|
478
|
-
const body = asRecord(await response
|
|
1533
|
+
const body = asRecord(await readQuotaJson(response));
|
|
479
1534
|
if (!body) return null;
|
|
480
1535
|
const fiveHour = parseClaudeBucket(body.five_hour);
|
|
481
1536
|
const sevenDay = parseClaudeBucket(body.seven_day);
|
|
1537
|
+
const fable = parseClaudeBucket(body.seven_day_fable);
|
|
482
1538
|
const opus = parseClaudeBucket(body.seven_day_opus);
|
|
483
1539
|
const sonnet = parseClaudeBucket(body.seven_day_sonnet);
|
|
484
1540
|
const customWindows: ProviderQuotaWindow[] = [];
|
|
1541
|
+
if (fable?.percent !== undefined) customWindows.push({ label: "Fable", percent: fable.percent, ...(fable.resetAt !== undefined ? { resetAt: fable.resetAt } : {}) });
|
|
485
1542
|
if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) });
|
|
486
1543
|
if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) });
|
|
1544
|
+
const knownLabels = new Set(customWindows.map(window => window.label.toLowerCase()));
|
|
1545
|
+
const limits = Array.isArray(body.limits) ? body.limits : [];
|
|
1546
|
+
for (const rawLimit of limits) {
|
|
1547
|
+
const limitRecord = asRecord(rawLimit);
|
|
1548
|
+
// `session` and `weekly_all` mirror the canonical five-hour and weekly
|
|
1549
|
+
// buckets above; only model-scoped weekly limits add a third window.
|
|
1550
|
+
if (String(limitRecord?.kind ?? "").trim().toLowerCase() !== "weekly_scoped") continue;
|
|
1551
|
+
const limit = parseClaudeLimit(rawLimit);
|
|
1552
|
+
if (!limit || knownLabels.has(limit.label.toLowerCase())) continue;
|
|
1553
|
+
knownLabels.add(limit.label.toLowerCase());
|
|
1554
|
+
customWindows.push(limit);
|
|
1555
|
+
}
|
|
487
1556
|
const quota: ProviderQuota = {
|
|
488
1557
|
// Claude's 5-hour window is a first-class rate limit, same as the Codex login 5h/weekly
|
|
489
1558
|
// rows: report it in the canonical fields so the dashboard renders it with the standard
|
|
@@ -640,6 +1709,13 @@ export function reconcileProviderAccountQuotaRows(context: GenerationContext): n
|
|
|
640
1709
|
return removed;
|
|
641
1710
|
}
|
|
642
1711
|
|
|
1712
|
+
/** Test-only reset so a direct reconcile call in one file cannot leak across files. */
|
|
1713
|
+
export function resetProviderQuotaReconcileStateForTests(): void {
|
|
1714
|
+
lastReconciledGeneration = 0;
|
|
1715
|
+
liveAccountQuotaKeys = new Set();
|
|
1716
|
+
liveProviderQuotaKeys = new Set();
|
|
1717
|
+
}
|
|
1718
|
+
|
|
643
1719
|
/** Drop cached per-account rows (all, or just one provider's). */
|
|
644
1720
|
export function clearAccountQuotaCache(provider?: string): void {
|
|
645
1721
|
if (!provider) {
|
|
@@ -781,7 +1857,7 @@ export async function fetchProviderAccountQuotas(
|
|
|
781
1857
|
function normalizedBaseUrl(value: string): string | null {
|
|
782
1858
|
try {
|
|
783
1859
|
const url = new URL(value);
|
|
784
|
-
if (url.search || url.hash) return null;
|
|
1860
|
+
if (url.username || url.password || url.search || url.hash) return null;
|
|
785
1861
|
return `${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`;
|
|
786
1862
|
} catch {
|
|
787
1863
|
return null;
|
|
@@ -796,6 +1872,12 @@ function isCanonicalKimiCodeBaseUrl(baseUrl: string): boolean {
|
|
|
796
1872
|
return normalizedBaseUrl(baseUrl) === KIMI_CODE_BASE_URL;
|
|
797
1873
|
}
|
|
798
1874
|
|
|
1875
|
+
function isCanonicalCommandCodeBaseUrl(baseUrl: string): boolean {
|
|
1876
|
+
const normalized = normalizedBaseUrl(baseUrl);
|
|
1877
|
+
// OAuth preset points at the API root; the Provider-API preset at /provider/v1.
|
|
1878
|
+
return normalized === COMMAND_CODE_BASE_URL || normalized === `${COMMAND_CODE_BASE_URL}/provider/v1`;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
799
1881
|
/** Prefer the nested `data` shell when the outer object is only an envelope. */
|
|
800
1882
|
function unwrapKimiQuotaPayload(value: unknown): Record<string, unknown> | null {
|
|
801
1883
|
const body = asRecord(value);
|
|
@@ -913,10 +1995,149 @@ async function fetchKimiQuota(provider: string, config: OcxProviderConfig): Prom
|
|
|
913
1995
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
914
1996
|
});
|
|
915
1997
|
if (!response.ok) return null;
|
|
916
|
-
const quota = parseKimiQuotaPayload(await response
|
|
1998
|
+
const quota = parseKimiQuotaPayload(await readQuotaJson(response));
|
|
917
1999
|
return quota ? report(provider, "kimi:usages", quota) : null;
|
|
918
2000
|
}
|
|
919
2001
|
|
|
2002
|
+
/**
|
|
2003
|
+
* Command Code rolling window: `{ cap, used, resetAt }` off /alpha/billing/credits,
|
|
2004
|
+
* normalized to a percent with an optional reset timestamp.
|
|
2005
|
+
*/
|
|
2006
|
+
function parseCommandCodeWindow(value: unknown): { percent: number; resetAt?: number } | null {
|
|
2007
|
+
const row = asRecord(value);
|
|
2008
|
+
if (!row) return null;
|
|
2009
|
+
const cap = toFiniteNumber(row.cap);
|
|
2010
|
+
const used = toFiniteNumber(row.used);
|
|
2011
|
+
if (cap === undefined || used === undefined || cap <= 0 || used < 0) return null;
|
|
2012
|
+
const percent = normalizePercent((used / cap) * 100);
|
|
2013
|
+
if (percent === undefined) return null;
|
|
2014
|
+
const resetAt = quotaResetAt(row);
|
|
2015
|
+
return { percent, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
/** Soft-fail GET returning a parsed record, or null when unavailable. */
|
|
2019
|
+
async function fetchCommandCodeJson(url: string, bearer: string): Promise<Record<string, unknown> | null> {
|
|
2020
|
+
try {
|
|
2021
|
+
const response = await fetch(url, {
|
|
2022
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` },
|
|
2023
|
+
redirect: "error",
|
|
2024
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
2025
|
+
});
|
|
2026
|
+
if (!response.ok) return null;
|
|
2027
|
+
return asRecord(await readQuotaJson(response));
|
|
2028
|
+
} catch {
|
|
2029
|
+
return null;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
/**
|
|
2034
|
+
* Soft-fail period spend (used) against the remaining credit pools → creditsUsd.
|
|
2035
|
+
* Period scoping: `since=<currentPeriodStart>` keeps spend aligned with the
|
|
2036
|
+
* pools' billing cycle, and `currentPeriodEnd` becomes expiresAt.
|
|
2037
|
+
*/
|
|
2038
|
+
async function fetchCommandCodeSpend(
|
|
2039
|
+
bearer: string,
|
|
2040
|
+
credits: Record<string, unknown> | null,
|
|
2041
|
+
orgQuery: string,
|
|
2042
|
+
): Promise<ProviderQuotaCreditsUsd | undefined> {
|
|
2043
|
+
if (!credits) return undefined;
|
|
2044
|
+
const subscriptionBody = await fetchCommandCodeJson(`${COMMAND_CODE_SUBSCRIPTIONS_URL}${orgQuery}`, bearer);
|
|
2045
|
+
const subscription = asRecord(subscriptionBody?.data) ?? subscriptionBody;
|
|
2046
|
+
const periodStart = typeof subscription?.currentPeriodStart === "string" ? subscription.currentPeriodStart.trim() : "";
|
|
2047
|
+
// Unscoped /usage/summary is lifetime spend; mixing it with current-cycle
|
|
2048
|
+
// remaining pools produces a wrong percent. Omit creditsUsd until a period exists.
|
|
2049
|
+
if (!periodStart) return undefined;
|
|
2050
|
+
const sinceQuery = `${orgQuery ? "&" : "?"}since=${encodeURIComponent(periodStart)}`;
|
|
2051
|
+
const expiresAt = normalizeResetAt(subscription?.currentPeriodEnd);
|
|
2052
|
+
const summaryBody = await fetchCommandCodeJson(`${COMMAND_CODE_USAGE_URL}${orgQuery}${sinceQuery}`, bearer);
|
|
2053
|
+
const summary = asRecord(summaryBody?.data) ?? summaryBody;
|
|
2054
|
+
const used = toFiniteNumber(summary?.totalCost) ?? toFiniteNumber(summary?.totalMonthlyCredits);
|
|
2055
|
+
if (used === undefined || used < 0) return undefined;
|
|
2056
|
+
const pools = [credits.monthlyCredits, credits.purchasedCredits, credits.freeCredits]
|
|
2057
|
+
.map(value => toFiniteNumber(value))
|
|
2058
|
+
.filter((value): value is number => value !== undefined);
|
|
2059
|
+
// Field presence is what separates a real balance from absent data: an exhausted
|
|
2060
|
+
// all-zero account still reports remaining=0, while no remaining-credit field at
|
|
2061
|
+
// all means there is nothing to meter.
|
|
2062
|
+
if (pools.length === 0) return undefined;
|
|
2063
|
+
const remaining = pools.reduce((sum, value) => sum + Math.max(0, value ?? 0), 0);
|
|
2064
|
+
const limit = used + remaining;
|
|
2065
|
+
const percent = normalizePercent(limit > 0 ? (used / limit) * 100 : 0);
|
|
2066
|
+
// Purchased credits roll over past the subscription period end, so an expiry is
|
|
2067
|
+
// only truthful when the aggregate contains no non-expiring purchased pool.
|
|
2068
|
+
const purchased = toFiniteNumber(credits.purchasedCredits) ?? 0;
|
|
2069
|
+
return percent === undefined
|
|
2070
|
+
? undefined
|
|
2071
|
+
: {
|
|
2072
|
+
used,
|
|
2073
|
+
limit,
|
|
2074
|
+
remaining,
|
|
2075
|
+
percent,
|
|
2076
|
+
...(expiresAt !== undefined && purchased <= 0 ? { expiresAt } : {}),
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
/** OAuth access token or ACTIVE Provider-API key for the Command Code quota probe. */
|
|
2081
|
+
async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig): Promise<string | null> {
|
|
2082
|
+
if (config.authMode === "oauth") {
|
|
2083
|
+
try {
|
|
2084
|
+
return await getValidAccessToken("command-code");
|
|
2085
|
+
} catch {
|
|
2086
|
+
return null;
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
// ACTIVE key only: a quota bar for a different account than the one routing
|
|
2090
|
+
// requests is a wrong meter, not a helpful one.
|
|
2091
|
+
return resolveEnvValue(config.apiKey)?.trim() || null;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/**
|
|
2095
|
+
* Command Code `GET /alpha/billing/credits` — the same Bearer surface the CLI's
|
|
2096
|
+
* usage view uses (windowLimits.fiveHour / windowLimits.weekly), plus soft
|
|
2097
|
+
* whoami (team orgId scoping) and subscription-scoped spend for creditsUsd.
|
|
2098
|
+
*/
|
|
2099
|
+
async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
2100
|
+
// Never release credentials to a user-edited or lookalike provider host.
|
|
2101
|
+
if (!isCanonicalCommandCodeBaseUrl(config.baseUrl)) return null;
|
|
2102
|
+
const bearer = await resolveCommandCodeQuotaBearer(config);
|
|
2103
|
+
if (!bearer) return null;
|
|
2104
|
+
const whoamiBody = await fetchCommandCodeJson(COMMAND_CODE_WHOAMI_URL, bearer);
|
|
2105
|
+
const whoami = asRecord(whoamiBody?.data) ?? whoamiBody;
|
|
2106
|
+
const org = asRecord(whoami?.org);
|
|
2107
|
+
const orgId = typeof org?.id === "string" && org.id.trim() ? org.id.trim() : null;
|
|
2108
|
+
const orgQuery = orgId ? `?orgId=${encodeURIComponent(orgId)}` : "";
|
|
2109
|
+
const response = await fetch(`${COMMAND_CODE_CREDITS_URL}${orgQuery}`, {
|
|
2110
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` },
|
|
2111
|
+
redirect: "error",
|
|
2112
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
2113
|
+
});
|
|
2114
|
+
if (!response.ok) {
|
|
2115
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
2116
|
+
? TERMINAL_QUOTA_FAILURE
|
|
2117
|
+
: null;
|
|
2118
|
+
}
|
|
2119
|
+
const raw = asRecord(await readQuotaJson(response));
|
|
2120
|
+
const body = asRecord(raw?.data) ?? raw;
|
|
2121
|
+
const credits = asRecord(body?.credits);
|
|
2122
|
+
const limits = asRecord(body?.windowLimits);
|
|
2123
|
+
if (!credits && !limits) return null;
|
|
2124
|
+
const fiveHour = parseCommandCodeWindow(limits?.fiveHour);
|
|
2125
|
+
const weekly = parseCommandCodeWindow(limits?.weekly);
|
|
2126
|
+
const creditsUsd = await fetchCommandCodeSpend(bearer, credits, orgQuery);
|
|
2127
|
+
return report(provider, "command-code:credits", {
|
|
2128
|
+
...(fiveHour ? {
|
|
2129
|
+
fiveHourPercent: fiveHour.percent,
|
|
2130
|
+
...(fiveHour.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}),
|
|
2131
|
+
} : {}),
|
|
2132
|
+
...(weekly ? {
|
|
2133
|
+
weeklyPercent: weekly.percent,
|
|
2134
|
+
...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}),
|
|
2135
|
+
} : {}),
|
|
2136
|
+
...(creditsUsd ? { creditsUsd } : {}),
|
|
2137
|
+
updatedAt: Date.now(),
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
|
|
920
2141
|
/** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */
|
|
921
2142
|
async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
922
2143
|
let accessToken: string;
|
|
@@ -946,7 +2167,7 @@ async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport |
|
|
|
946
2167
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
947
2168
|
});
|
|
948
2169
|
if (periodRes.ok) {
|
|
949
|
-
const body = asRecord(await periodRes
|
|
2170
|
+
const body = asRecord(await readQuotaJson(periodRes));
|
|
950
2171
|
const planUsage = asRecord(body?.planUsage);
|
|
951
2172
|
if (planUsage) {
|
|
952
2173
|
const resetAt = normalizeResetAt(body?.billingCycleEnd ?? planUsage.billingCycleEnd ?? body?.periodEnd);
|
|
@@ -1008,7 +2229,7 @@ async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport |
|
|
|
1008
2229
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1009
2230
|
});
|
|
1010
2231
|
if (summaryRes.ok) {
|
|
1011
|
-
const body = asRecord(await summaryRes
|
|
2232
|
+
const body = asRecord(await readQuotaJson(summaryRes));
|
|
1012
2233
|
const individual = asRecord(body?.individualUsage);
|
|
1013
2234
|
const plan = asRecord(individual?.plan);
|
|
1014
2235
|
if (plan) {
|
|
@@ -1037,7 +2258,7 @@ async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport |
|
|
|
1037
2258
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1038
2259
|
});
|
|
1039
2260
|
if (!response.ok) return null;
|
|
1040
|
-
const body = asRecord(await response
|
|
2261
|
+
const body = asRecord(await readQuotaJson(response));
|
|
1041
2262
|
if (!body) return null;
|
|
1042
2263
|
|
|
1043
2264
|
// Prefer the gpt-4 bucket (historical "fast requests"); else first model with used+limit.
|
|
@@ -1151,7 +2372,7 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig
|
|
|
1151
2372
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1152
2373
|
});
|
|
1153
2374
|
if (!response.ok) return null;
|
|
1154
|
-
const body = asRecord(await response
|
|
2375
|
+
const body = asRecord(await readQuotaJson(response));
|
|
1155
2376
|
const models = asRecord(body?.models);
|
|
1156
2377
|
if (!models) return null;
|
|
1157
2378
|
|
|
@@ -1188,10 +2409,13 @@ async function maybeFetchProviderQuota(
|
|
|
1188
2409
|
provider: OcxProviderConfig,
|
|
1189
2410
|
config: OcxConfig,
|
|
1190
2411
|
forceRefresh: boolean,
|
|
1191
|
-
|
|
2412
|
+
prefetchedCodexSnapshot?: CodexAuthAccountsSnapshotPromise,
|
|
2413
|
+
): Promise<ProviderQuotaProbeResult> {
|
|
1192
2414
|
if (provider.disabled === true) return null;
|
|
1193
2415
|
try {
|
|
1194
|
-
if (isBuiltInChatGptForwardProvider(name, provider))
|
|
2416
|
+
if (isBuiltInChatGptForwardProvider(name, provider)) {
|
|
2417
|
+
return fetchChatGptForwardQuota(config, name, provider, forceRefresh, prefetchedCodexSnapshot);
|
|
2418
|
+
}
|
|
1195
2419
|
if (provider.authMode === "oauth" && name === "xai") return fetchXaiQuota(name);
|
|
1196
2420
|
if (provider.authMode === "oauth" && name === "anthropic") return fetchAnthropicQuota(name);
|
|
1197
2421
|
if (provider.authMode === "oauth" && name === "cursor") return fetchCursorQuota(name);
|
|
@@ -1202,6 +2426,59 @@ async function maybeFetchProviderQuota(
|
|
|
1202
2426
|
if (provider.authMode === "key" && isCanonicalKimiCodeBaseUrl(provider.baseUrl)) {
|
|
1203
2427
|
return fetchKimiQuota(name, provider);
|
|
1204
2428
|
}
|
|
2429
|
+
// OAuth account login or Provider-API key only; forward/local modes carry no
|
|
2430
|
+
// credential of ours on the canonical host.
|
|
2431
|
+
if (provider.authMode === "oauth" && name === "command-code") {
|
|
2432
|
+
return fetchCommandCodeQuota(name, provider);
|
|
2433
|
+
}
|
|
2434
|
+
if ((provider.authMode ?? "key") === "key" && name === "commandcode"
|
|
2435
|
+
&& isCanonicalCommandCodeBaseUrl(provider.baseUrl)) {
|
|
2436
|
+
return fetchCommandCodeQuota(name, provider);
|
|
2437
|
+
}
|
|
2438
|
+
// Identify OpenCode Go by where it routes, not by what the row is called. Multi-account
|
|
2439
|
+
// setups keep the same destination under names like `opencode-go-2` (#1924), and those rows
|
|
2440
|
+
// silently had no quota panel and no `ocx provider quota --json` report while the literal
|
|
2441
|
+
// name was the gate. `registryEntryForProviderDestination` is the existing predicate for
|
|
2442
|
+
// exactly this question: normalized endpoint + adapter + key auth, so a canonical URL behind
|
|
2443
|
+
// a different adapter is still not OpenCode Go. The defensive URL check inside
|
|
2444
|
+
// `fetchOpenCodeGoQuota` stays — sending a key anywhere must not depend on this gate.
|
|
2445
|
+
if ((provider.authMode ?? "key") === "key" && registryEntryForProviderDestination(provider)?.id === "opencode-go") {
|
|
2446
|
+
return fetchOpenCodeGoQuota(name, provider);
|
|
2447
|
+
}
|
|
2448
|
+
if ((provider.authMode ?? "key") === "key" && isCanonicalA6apiBaseUrl(provider.baseUrl)) {
|
|
2449
|
+
return fetchA6apiQuota(name, provider);
|
|
2450
|
+
}
|
|
2451
|
+
if ((provider.authMode ?? "key") === "key" && name === "openrouter") {
|
|
2452
|
+
return fetchOpenRouterQuota(name, provider);
|
|
2453
|
+
}
|
|
2454
|
+
if ((provider.authMode ?? "key") === "key" && name === "deepseek") {
|
|
2455
|
+
return fetchDeepSeekQuota(name, provider);
|
|
2456
|
+
}
|
|
2457
|
+
if ((provider.authMode ?? "key") === "key" && name === "cline-pass") {
|
|
2458
|
+
return fetchClineQuota(name, provider);
|
|
2459
|
+
}
|
|
2460
|
+
if ((provider.authMode ?? "key") === "key"
|
|
2461
|
+
&& (name === "zai" || name === "glm" || name === "glm-cn" || name === "zhipu-bigmodel-coding")) {
|
|
2462
|
+
return fetchZaiQuota(name, provider);
|
|
2463
|
+
}
|
|
2464
|
+
if ((provider.authMode ?? "key") === "key" && (name === "minimax" || name === "minimax-cn")) {
|
|
2465
|
+
return fetchMinimaxQuota(name, provider);
|
|
2466
|
+
}
|
|
2467
|
+
if ((provider.authMode ?? "key") === "key" && name === "moonshot") {
|
|
2468
|
+
return fetchMoonshotQuota(name, provider);
|
|
2469
|
+
}
|
|
2470
|
+
if ((provider.authMode ?? "key") === "key" && name === "venice") {
|
|
2471
|
+
return fetchVeniceQuota(name, provider);
|
|
2472
|
+
}
|
|
2473
|
+
if ((provider.authMode ?? "key") === "key" && name === "synthetic") {
|
|
2474
|
+
return fetchSyntheticQuota(name, provider);
|
|
2475
|
+
}
|
|
2476
|
+
if ((provider.authMode ?? "key") === "key" && name === "deepinfra") {
|
|
2477
|
+
return fetchDeepInfraQuota(name, provider);
|
|
2478
|
+
}
|
|
2479
|
+
if ((provider.authMode ?? "key") === "key" && name === "neuralwatt") {
|
|
2480
|
+
return fetchNeuralwattQuota(name, provider);
|
|
2481
|
+
}
|
|
1205
2482
|
return null;
|
|
1206
2483
|
} catch {
|
|
1207
2484
|
return null;
|
|
@@ -1209,41 +2486,96 @@ async function maybeFetchProviderQuota(
|
|
|
1209
2486
|
}
|
|
1210
2487
|
|
|
1211
2488
|
export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh = false): Promise<ProviderQuotaResponse> {
|
|
1212
|
-
|
|
2489
|
+
// A Pool report's cache signature and provider fetch must share one account snapshot.
|
|
2490
|
+
// Preserve force semantics when deciding whether that snapshot refreshes upstream data.
|
|
2491
|
+
const prefetchedCodexSnapshot = hasCodexPoolProvider(config)
|
|
2492
|
+
? listCodexAuthAccountsSnapshot(config, forceRefresh)
|
|
2493
|
+
: undefined;
|
|
2494
|
+
const keyCandidate = cacheKeyWithAggregationState(config, prefetchedCodexSnapshot);
|
|
2495
|
+
const key = typeof keyCandidate === "string" ? keyCandidate : await keyCandidate;
|
|
1213
2496
|
const writerGeneration = captureConfigGeneration();
|
|
1214
2497
|
const now = Date.now();
|
|
1215
2498
|
// The cache fast path must not extend a preserved last-good row past its 30-minute bound:
|
|
1216
2499
|
// a row preserved at age 29:59 plus a full 5-minute TTL would otherwise serve until ~35min.
|
|
1217
2500
|
const cacheFresh = cache && cache.key === key && now - cache.ts < CACHE_TTL_MS
|
|
1218
|
-
&& cache.response.reports.every(item =>
|
|
2501
|
+
&& cache.response.reports.every(item =>
|
|
2502
|
+
now - item.updatedAt < LAST_GOOD_MAX_AGE_MS && isProviderQuotaReportCurrent(item));
|
|
1219
2503
|
if (!forceRefresh && cacheFresh) return cache!.response;
|
|
2504
|
+
|
|
2505
|
+
// Stale-while-revalidate for the Providers overview: when the TTL has expired but last-good
|
|
2506
|
+
// rows are still within LAST_GOOD_MAX_AGE_MS, paint them immediately and refresh in the
|
|
2507
|
+
// background. A cold load with no cache still waits on the probe (nothing to show yet).
|
|
1220
2508
|
const joinable = inflight.get(key);
|
|
1221
|
-
if (!forceRefresh && joinable && joinable.epoch === invalidationEpoch)
|
|
2509
|
+
if (!forceRefresh && joinable && joinable.epoch === invalidationEpoch) {
|
|
2510
|
+
if (cache && cache.key === key
|
|
2511
|
+
&& cache.response.reports.every(item => now - item.updatedAt < LAST_GOOD_MAX_AGE_MS)) {
|
|
2512
|
+
return cache.response;
|
|
2513
|
+
}
|
|
2514
|
+
return joinable.promise;
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
const staleUsable = !forceRefresh
|
|
2518
|
+
&& cache
|
|
2519
|
+
&& cache.key === key
|
|
2520
|
+
&& cache.response.reports.every(item => now - item.updatedAt < LAST_GOOD_MAX_AGE_MS);
|
|
2521
|
+
|
|
1222
2522
|
// A forced probe takes commit authority: older in-flight probes must not overwrite its result.
|
|
1223
2523
|
if (forceRefresh) invalidationEpoch += 1;
|
|
1224
2524
|
const epoch = invalidationEpoch;
|
|
1225
2525
|
|
|
1226
2526
|
const promise = (async (): Promise<ProviderQuotaResponse> => {
|
|
1227
2527
|
const previous = cache && cache.key === key ? cache.response.reports : [];
|
|
1228
|
-
const
|
|
1229
|
-
Object.entries(config.providers).map(([name, provider]) =>
|
|
1230
|
-
|
|
2528
|
+
const probeResults = await Promise.all(
|
|
2529
|
+
Object.entries(config.providers).map(([name, provider]) => (
|
|
2530
|
+
maybeFetchProviderQuota(name, provider, config, forceRefresh, prefetchedCodexSnapshot)
|
|
2531
|
+
)),
|
|
2532
|
+
);
|
|
2533
|
+
const fresh = probeResults.filter((item): item is ProviderQuotaReport => item !== null && item !== TERMINAL_QUOTA_FAILURE);
|
|
2534
|
+
const terminalFailures = new Set(
|
|
2535
|
+
Object.keys(config.providers).filter((_, index) => probeResults[index] === TERMINAL_QUOTA_FAILURE),
|
|
2536
|
+
);
|
|
2537
|
+
await providerQuotaBeforePublishForTests?.();
|
|
2538
|
+
let commitKey: string | null = null;
|
|
2539
|
+
if (epoch === invalidationEpoch) {
|
|
2540
|
+
const commitKeyCandidate = cacheKeyWithAggregationState(config);
|
|
2541
|
+
commitKey = typeof commitKeyCandidate === "string" ? commitKeyCandidate : await commitKeyCandidate;
|
|
2542
|
+
}
|
|
1231
2543
|
|
|
1232
|
-
// Keep bounded last-good rows when a probe fails
|
|
1233
|
-
//
|
|
2544
|
+
// Keep bounded last-good rows when a probe fails transiently; terminal-invalid provider
|
|
2545
|
+
// responses explicitly suppress their old row. Never re-stamp preserved timestamps.
|
|
1234
2546
|
// Note: the cache key encodes the provider set (name/adapter/authMode/disabled/baseUrl),
|
|
1235
2547
|
// so previous rows always correspond to currently configured, enabled providers — a
|
|
1236
2548
|
// disabled or removed provider changes the key and starts from an empty previous set.
|
|
1237
2549
|
const cutoff = Date.now() - LAST_GOOD_MAX_AGE_MS;
|
|
1238
2550
|
const byProvider = new Map<string, ProviderQuotaReport>();
|
|
2551
|
+
const generationMismatchedProviders = new Set<string>();
|
|
1239
2552
|
for (const item of previous) {
|
|
1240
|
-
if (item.updatedAt
|
|
2553
|
+
if (item.updatedAt < cutoff) continue;
|
|
2554
|
+
if (isProviderQuotaReportCurrent(item)) byProvider.set(item.provider, item);
|
|
2555
|
+
else generationMismatchedProviders.add(item.provider);
|
|
2556
|
+
}
|
|
2557
|
+
for (const item of fresh) {
|
|
2558
|
+
if (isProviderQuotaReportCurrent(item)) {
|
|
2559
|
+
byProvider.set(item.provider, item);
|
|
2560
|
+
generationMismatchedProviders.delete(item.provider);
|
|
2561
|
+
} else {
|
|
2562
|
+
byProvider.delete(item.provider);
|
|
2563
|
+
generationMismatchedProviders.add(item.provider);
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
// Terminal-invalid probes suppress their previous row (transient failures keep it).
|
|
2567
|
+
for (const provider of terminalFailures) {
|
|
2568
|
+
byProvider.delete(provider);
|
|
2569
|
+
generationMismatchedProviders.delete(provider);
|
|
1241
2570
|
}
|
|
1242
|
-
for (const item of fresh) byProvider.set(item.provider, item);
|
|
1243
2571
|
|
|
1244
2572
|
const response = { generatedAt: Date.now(), reports: [...byProvider.values()] };
|
|
1245
2573
|
// Commit only when this probe still holds authority (no clear/force superseded it).
|
|
1246
|
-
if (
|
|
2574
|
+
if (
|
|
2575
|
+
epoch === invalidationEpoch
|
|
2576
|
+
&& commitKey === key
|
|
2577
|
+
&& generationMismatchedProviders.size === 0
|
|
2578
|
+
) {
|
|
1247
2579
|
const reports = response.reports.filter(item => mayCommitProviderQuotaKey(item.provider, writerGeneration));
|
|
1248
2580
|
cache = { key, ts: Date.now(), response: { ...response, reports } };
|
|
1249
2581
|
}
|
|
@@ -1252,6 +2584,13 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh
|
|
|
1252
2584
|
|
|
1253
2585
|
const entry = { epoch, promise };
|
|
1254
2586
|
inflight.set(key, entry);
|
|
2587
|
+
if (staleUsable) {
|
|
2588
|
+
// Detach: do not await. Callers get last-good now; the flight still fills the cache.
|
|
2589
|
+
void promise.finally(() => {
|
|
2590
|
+
if (inflight.get(key) === entry) inflight.delete(key);
|
|
2591
|
+
});
|
|
2592
|
+
return cache!.response;
|
|
2593
|
+
}
|
|
1255
2594
|
try {
|
|
1256
2595
|
return await promise;
|
|
1257
2596
|
} finally {
|