@bitkyc08/opencodex 2.55.0 → 2.56.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/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +2 -1
- package/src/adapters/base.ts +21 -0
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-schema.ts +477 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +33 -1445
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +611 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +34 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +624 -0
- package/src/bridge/sse.ts +1444 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +71 -9
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +554 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +422 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +95 -28
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +304 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +706 -0
- package/src/codex/catalog/routed-gather.ts +858 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +540 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +81 -1436
- package/src/codex/lineage.ts +458 -0
- package/src/codex/pool-refresh-backoff.ts +152 -0
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cooldown-math.ts +275 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +703 -0
- package/src/codex/routing/thread-affinity.ts +538 -0
- package/src/codex/routing.ts +353 -2234
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +539 -39
- package/src/lib/request-execution-budget.ts +69 -20
- package/src/lib/spend-reservation-ledger.ts +940 -0
- package/src/lib/upstream-retry.ts +55 -11
- package/src/lib/workflow-budget.ts +553 -30
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1221 -0
- package/src/providers/registry/entries-extended.ts +1204 -0
- package/src/providers/registry/model-seeds.ts +908 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +449 -0
- package/src/routing/probe-lease.ts +511 -0
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +565 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +335 -0
- package/src/server/index.ts +40 -2547
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +245 -1
- package/src/server/responses/account-change-state.ts +233 -0
- package/src/server/responses/adapter-continuation.ts +514 -0
- package/src/server/responses/adapter-delivery.ts +214 -0
- package/src/server/responses/adapter-dispatch.ts +971 -0
- package/src/server/responses/compact.ts +59 -4
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +527 -0
- package/src/server/responses/core-codex-account.ts +859 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +707 -0
- package/src/server/responses/core-errors.ts +152 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +225 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/passthrough-delivery.ts +856 -0
- package/src/server/responses/passthrough-dispatch.ts +1476 -0
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +970 -0
- package/src/server/responses/request-send-budget.ts +164 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-transport.ts +744 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +448 -0
- package/src/server/responses/sidecar-execution.ts +469 -0
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/workflow-refusal.ts +84 -0
- package/src/types/config.ts +30 -0
- package/src/usage/log.ts +146 -0
- package/src/usage/summary.ts +171 -21
package/src/providers/quota.ts
CHANGED
|
@@ -1,3078 +1,102 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
effectiveCodexAuthAccountId,
|
|
4
|
-
fetchMainAccountInfoSnapshot,
|
|
5
|
-
listCodexAuthAccountsSnapshot,
|
|
6
|
-
} from "../codex/auth-api";
|
|
7
|
-
import { withoutRetiredCodexQuota, type StoredAccountQuota } from "../codex/quota";
|
|
8
|
-
import { isMainAccountIdentityGenerationLive } from "../codex/main-account-cache";
|
|
9
|
-
import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
|
|
10
|
-
import { codexPlanKey } from "../codex/plan";
|
|
11
|
-
import { resolveEnvValue } from "../config";
|
|
12
|
-
import { resolveProviderApiKey } from "./key-store";
|
|
13
|
-
import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth";
|
|
14
|
-
import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store";
|
|
15
|
-
import { antigravityUserAgent } from "../adapters/client-fingerprint";
|
|
16
|
-
import { isCanonicalOllamaCloudUrl } from "../adapters/ollama-native-url";
|
|
17
|
-
import { DestinationDnsResolutionError } from "../lib/destination-policy";
|
|
18
|
-
import { PinnedHttpError } from "../lib/pinned-http";
|
|
19
|
-
import { ProviderOutboundPolicyError, providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../lib/provider-outbound";
|
|
20
|
-
import { apiKeyPoolEntryId } from "./api-keys";
|
|
21
|
-
import { fetchMuseKeyQuotaSnapshot } from "./muse-key-quota";
|
|
22
|
-
import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport";
|
|
23
|
-
import { getProviderRegistryEntry, providerCodexAccountMode, registryEntryForProviderDestination } from "./registry";
|
|
24
|
-
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
25
|
-
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers";
|
|
26
|
-
import {
|
|
27
|
-
captureConfigGeneration,
|
|
28
|
-
sweepExpiredOnWrite,
|
|
29
|
-
type GenerationContext,
|
|
30
|
-
} from "../lib/state-store-sweeper";
|
|
31
|
-
import {
|
|
32
|
-
ACCOUNT_QUOTA_TTL_MS,
|
|
33
|
-
asRecord,
|
|
34
|
-
CACHE_TTL_MS,
|
|
35
|
-
normalizePercent,
|
|
36
|
-
normalizeResetAt,
|
|
37
|
-
QUOTA_JSON_READ_FAILURE,
|
|
38
|
-
readQuotaJson,
|
|
39
|
-
REQUEST_TIMEOUT_MS,
|
|
40
|
-
toFiniteNumber,
|
|
41
|
-
} from "./quota-wire";
|
|
42
|
-
import {
|
|
43
|
-
clearCachedProviderQuotas,
|
|
44
|
-
providerQuotaRoutingBinding,
|
|
45
|
-
replaceCachedProviderQuotas,
|
|
46
|
-
type ProviderQuotaRoutingEvidence,
|
|
47
|
-
} from "./quota-routing-cache";
|
|
48
|
-
import {
|
|
49
|
-
aggregateCodexPoolCapacity,
|
|
50
|
-
CODEX_CAPACITY_MAX_QUOTA_AGE_MS,
|
|
51
|
-
type CodexCapacityAggregation,
|
|
52
|
-
type CodexCapacityQuota,
|
|
53
|
-
} from "./codex-capacity";
|
|
54
|
-
import type {
|
|
55
|
-
AccountQuotaMode,
|
|
56
|
-
QuotaFailureCode,
|
|
57
|
-
ProviderQuota,
|
|
58
|
-
ProviderQuotaCreditsUsd,
|
|
59
|
-
ProviderQuotaWindow,
|
|
60
|
-
ProviderRoutingQuota,
|
|
61
|
-
} from "./quota-types";
|
|
62
|
-
import {
|
|
63
|
-
clearKiroAccountUsageState,
|
|
64
|
-
commitKiroAccountUsageState,
|
|
65
|
-
fetchKiroUsageSnapshot,
|
|
66
|
-
type KiroUsageSnapshot,
|
|
67
|
-
kiroUsageContextForAccount,
|
|
68
|
-
reconcileKiroAccountUsageState,
|
|
69
|
-
} from "./kiro-usage";
|
|
70
|
-
import {
|
|
71
|
-
cancelPendingAccountQuotaPersist,
|
|
72
|
-
readPersistedAccountQuotas,
|
|
73
|
-
schedulePersistAccountQuotas,
|
|
74
|
-
} from "./account-quota-disk";
|
|
75
|
-
import { clearProviderApiKeyQuotaCache, mapQuotaRoster, readProviderApiKeyQuotas, type ProviderApiKeyQuota } from "./quota-key-accounts";
|
|
76
|
-
|
|
77
|
-
export type { ProviderQuota, ProviderQuotaCreditsUsd, ProviderQuotaWindow } from "./quota-types";
|
|
78
|
-
|
|
79
|
-
/** Match oauth/index REFRESH_SKEW_MS — use stored access without refresh when still fresh. */
|
|
80
|
-
const ACCOUNT_TOKEN_SKEW_MS = 60_000;
|
|
81
|
-
/** Successful provider quota payloads are small; reject oversized or stalled JSON before parsing. */
|
|
82
|
-
export { QUOTA_RESPONSE_MAX_BYTES } from "./quota-wire";
|
|
83
|
-
const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
|
|
84
|
-
const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`;
|
|
85
|
-
const COMMAND_CODE_BASE_URL = "https://api.commandcode.ai";
|
|
86
|
-
const COMMAND_CODE_WHOAMI_URL = `${COMMAND_CODE_BASE_URL}/alpha/whoami`;
|
|
87
|
-
const COMMAND_CODE_CREDITS_URL = `${COMMAND_CODE_BASE_URL}/alpha/billing/credits`;
|
|
88
|
-
const COMMAND_CODE_SUBSCRIPTIONS_URL = `${COMMAND_CODE_BASE_URL}/alpha/billing/subscriptions`;
|
|
89
|
-
const COMMAND_CODE_USAGE_URL = `${COMMAND_CODE_BASE_URL}/alpha/usage/summary`;
|
|
90
|
-
const A6API_BASE_URL = "https://api.a6api.com";
|
|
91
|
-
const OPENCODE_GO_BASE_URL = "https://opencode.ai/zen/go/v1";
|
|
92
|
-
const OPENCODE_GO_USAGE_URL = `${OPENCODE_GO_BASE_URL}/usage`;
|
|
93
|
-
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
|
94
|
-
const DEEPSEEK_BASE_URL = "https://api.deepseek.com";
|
|
95
|
-
const CLINE_BASE_URL = "https://api.cline.bot";
|
|
96
|
-
const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
|
|
97
|
-
const OLLAMA_CLOUD_USAGE_URL = `${OLLAMA_CLOUD_BASE_URL}/api/usage`;
|
|
98
|
-
const ZAI_BASE_URL = "https://api.z.ai";
|
|
99
|
-
const ZAI_CN_BASE_URL = "https://open.bigmodel.cn";
|
|
100
|
-
const MINIMAX_REMAINS_URL = "https://www.minimax.io/v1/token_plan/remains";
|
|
101
|
-
const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1";
|
|
102
|
-
const VENICE_BASE_URL = "https://api.venice.ai/api/v1";
|
|
103
|
-
const SYNTHETIC_BASE_URL = "https://api.synthetic.new/v2";
|
|
104
|
-
const DEEPINFRA_BASE_URL = "https://api.deepinfra.com";
|
|
105
|
-
const NEURALWATT_BASE_URL = "https://api.neuralwatt.com/v1";
|
|
106
|
-
const XAI_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing";
|
|
107
|
-
const XAI_CREDITS_URL = `${XAI_BILLING_URL}?format=credits`;
|
|
108
|
-
/** Keep a failed probe's previous row at most this long before dropping it. */
|
|
109
|
-
const LAST_GOOD_MAX_AGE_MS = CODEX_CAPACITY_MAX_QUOTA_AGE_MS;
|
|
110
|
-
const nativeMainReportGenerations = new WeakMap<ProviderQuotaReport, number>();
|
|
111
|
-
const accountReportCurrent = new WeakMap<ProviderQuotaReport, () => boolean>();
|
|
112
|
-
const routingEvidence = new WeakMap<ProviderQuotaReport, ProviderQuotaRoutingEvidence>();
|
|
113
|
-
let providerQuotaBeforePublishForTests: (() => void | Promise<void>) | null = null;
|
|
114
|
-
|
|
115
|
-
/** Test-only seam for identity/config invalidation after probes but before publication. */
|
|
116
|
-
export function setProviderQuotaBeforePublishForTests(
|
|
117
|
-
hook: (() => void | Promise<void>) | null,
|
|
118
|
-
): void {
|
|
119
|
-
providerQuotaBeforePublishForTests = hook;
|
|
120
|
-
}
|
|
121
|
-
const TERMINAL_QUOTA_FAILURE = Symbol("terminal-quota-failure");
|
|
122
|
-
/**
|
|
123
|
-
* The probe succeeded and the upstream authoritatively reported NO model-quota windows.
|
|
124
|
-
*
|
|
125
|
-
* Distinct from `null`, which means "this probe told us nothing" and deliberately preserves
|
|
126
|
-
* the last-good row for up to 30 minutes. Collapsing the two would let a stale report outlive
|
|
127
|
-
* the authoritative answer that replaced it: a GLM plan whose payload carries only MCP
|
|
128
|
-
* `TIME_LIMIT` rows has no model windows, and the dashboard and quota-aware routing must stop
|
|
129
|
-
* showing the previous token windows rather than keep them for another half hour.
|
|
130
|
-
*
|
|
131
|
-
* Suppression is shared with `TERMINAL_QUOTA_FAILURE`; only the reason differs.
|
|
132
|
-
*/
|
|
133
|
-
const AUTHORITATIVE_EMPTY_QUOTA = Symbol("authoritative-empty-quota");
|
|
134
|
-
type ProviderQuotaProbeResult =
|
|
135
|
-
| ProviderQuotaReport
|
|
136
|
-
| null
|
|
137
|
-
| typeof TERMINAL_QUOTA_FAILURE
|
|
138
|
-
| typeof AUTHORITATIVE_EMPTY_QUOTA;
|
|
139
|
-
|
|
140
|
-
export interface ProviderQuotaReport {
|
|
141
|
-
provider: string;
|
|
142
|
-
label: string;
|
|
143
|
-
source: string;
|
|
144
|
-
quota: ProviderQuota;
|
|
145
|
-
updatedAt: number;
|
|
146
|
-
/** Added by the management response projection, never stored on a cached report. */
|
|
147
|
-
routingQuota?: ProviderRoutingQuota;
|
|
148
|
-
reverseEngineered?: boolean;
|
|
149
|
-
/**
|
|
150
|
-
* The row was OBSERVED in-band on a streaming turn rather than probed.
|
|
151
|
-
*
|
|
152
|
-
* Age means something different for these. A probed provider re-reads on its own TTL,
|
|
153
|
-
* so a row older than the last-good bound means the probe is failing and showing it
|
|
154
|
-
* would misrepresent a live number. A passive provider publishes no endpoint at all
|
|
155
|
-
* (`hasPassiveAccountQuota`), so its last observation is not a stale reading of
|
|
156
|
-
* something fresher — it is the only measurement that exists, and dropping it leaves
|
|
157
|
-
* the operator with nothing. Consumers that enforce a freshness bound must exempt
|
|
158
|
-
* these and state the observation age instead.
|
|
159
|
-
*/
|
|
160
|
-
observed?: boolean;
|
|
161
|
-
aggregation?: CodexCapacityAggregation;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export interface ProviderQuotaResponse {
|
|
165
|
-
generatedAt: number;
|
|
166
|
-
reports: ProviderQuotaReport[];
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
let cache: { key: string; ts: number; response: ProviderQuotaResponse } | null = null;
|
|
170
|
-
const inflight = new Map<string, { epoch: number; promise: Promise<ProviderQuotaResponse> }>();
|
|
171
|
-
/** Bumped on cache clear and on force-refresh start; stale-epoch probes lose commit authority. */
|
|
172
|
-
let invalidationEpoch = 0;
|
|
173
|
-
|
|
174
|
-
/** Invalidate the report cache (e.g. after switching a provider's active account). */
|
|
175
|
-
export function clearProviderQuotaCache(): void {
|
|
176
|
-
cache = null;
|
|
177
|
-
clearCachedProviderQuotas();
|
|
178
|
-
clearProviderApiKeyQuotaCache();
|
|
179
|
-
invalidationEpoch += 1;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function cacheKey(config: OcxConfig): string {
|
|
183
|
-
const providers = Object.entries(config.providers)
|
|
184
|
-
.map(([name, provider]) => {
|
|
185
|
-
const resolvedKey = typeof provider.apiKey === "string"
|
|
186
|
-
? resolveProviderApiKey(provider.apiKey)?.trim()
|
|
187
|
-
: undefined;
|
|
188
|
-
const activeKeyId = resolvedKey ? apiKeyPoolEntryId(resolvedKey) : "none";
|
|
189
|
-
return `${name}:${provider.adapter}:${provider.authMode ?? "key"}:${providerCodexAccountMode(name, provider) ?? "none"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}:${activeKeyId}`;
|
|
190
|
-
})
|
|
191
|
-
.sort()
|
|
192
|
-
.join("|");
|
|
193
|
-
return `${config.defaultProvider}|${providers}`;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
type CodexAuthAccountsSnapshotPromise = ReturnType<typeof listCodexAuthAccountsSnapshot>;
|
|
197
|
-
|
|
198
|
-
function hasCodexPoolProvider(config: OcxConfig): boolean {
|
|
199
|
-
return Object.entries(config.providers).some(([name, provider]) => (
|
|
200
|
-
provider.disabled !== true
|
|
201
|
-
&& isBuiltInChatGptForwardProvider(name, provider)
|
|
202
|
-
&& providerCodexAccountMode(name, provider) !== "direct"
|
|
203
|
-
));
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function quotaSignatureValue(quota: CodexCapacityQuota | null): unknown {
|
|
207
|
-
if (!quota) return null;
|
|
208
|
-
return {
|
|
209
|
-
fiveHourPercent: quota.fiveHourPercent,
|
|
210
|
-
fiveHourResetAt: quota.fiveHourResetAt,
|
|
211
|
-
weeklyPercent: quota.weeklyPercent,
|
|
212
|
-
weeklyResetAt: quota.weeklyResetAt,
|
|
213
|
-
monthlyPercent: quota.monthlyPercent,
|
|
214
|
-
monthlyResetAt: quota.monthlyResetAt,
|
|
215
|
-
updatedAt: quota.updatedAt,
|
|
216
|
-
customWindows: [...(quota.customWindows ?? [])]
|
|
217
|
-
.map(window => ({ label: window.label, percent: window.percent, resetAt: window.resetAt }))
|
|
218
|
-
.sort((a, b) => a.label.localeCompare(b.label)),
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function providerQuotaFromCodexQuota(
|
|
223
|
-
quota: StoredAccountQuota | Omit<StoredAccountQuota, "updatedAt"> | null | undefined,
|
|
224
|
-
): CodexCapacityQuota | null {
|
|
225
|
-
if (!quota) return null;
|
|
226
|
-
// Direct snapshots bypass account DTOs; sanitize here as well as at ingestion.
|
|
227
|
-
quota = withoutRetiredCodexQuota(quota);
|
|
228
|
-
if (!quota) return null;
|
|
229
|
-
const projected: CodexCapacityQuota = {
|
|
230
|
-
...(quota.shortPercent !== undefined ? { fiveHourPercent: quota.shortPercent } : {}),
|
|
231
|
-
...(quota.shortResetAt !== undefined ? { fiveHourResetAt: quota.shortResetAt } : {}),
|
|
232
|
-
...(quota.weeklyPercent !== undefined ? { weeklyPercent: quota.weeklyPercent } : {}),
|
|
233
|
-
...(quota.weeklyResetAt !== undefined ? { weeklyResetAt: quota.weeklyResetAt } : {}),
|
|
234
|
-
...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}),
|
|
235
|
-
...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}),
|
|
236
|
-
...(quota.customWindows !== undefined ? { customWindows: quota.customWindows } : {}),
|
|
237
|
-
updatedAt: "updatedAt" in quota ? quota.updatedAt : Date.now(),
|
|
238
|
-
};
|
|
239
|
-
return hasQuotaRows(projected) ? projected : null;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** Hash only presentation-relevant state; account ids and email addresses never enter the key. */
|
|
243
|
-
function cacheKeyWithAggregationState(
|
|
244
|
-
config: OcxConfig,
|
|
245
|
-
prefetchedSnapshot?: CodexAuthAccountsSnapshotPromise,
|
|
246
|
-
): string | Promise<string> {
|
|
247
|
-
const base = cacheKey(config);
|
|
248
|
-
if (!hasCodexPoolProvider(config)) return base;
|
|
249
|
-
return (async () => {
|
|
250
|
-
try {
|
|
251
|
-
const activeId = effectiveCodexAuthAccountId(config);
|
|
252
|
-
const snapshot = await (prefetchedSnapshot ?? listCodexAuthAccountsSnapshot(config, false));
|
|
253
|
-
const rows = snapshot.accounts.map(account => ({
|
|
254
|
-
isMain: account.isMain,
|
|
255
|
-
active: account.id === activeId,
|
|
256
|
-
plan: codexPlanKey(account.plan) ?? null,
|
|
257
|
-
paused: account.paused,
|
|
258
|
-
needsReauth: account.needsReauth === true,
|
|
259
|
-
quota: quotaSignatureValue(providerQuotaFromCodexQuota(account.quota)),
|
|
260
|
-
}));
|
|
261
|
-
const canonicalRows = rows.map(row => JSON.stringify(row)).sort();
|
|
262
|
-
const digest = createHash("sha256").update(JSON.stringify(canonicalRows)).digest("hex").slice(0, 24);
|
|
263
|
-
return `${base}|codex-pool:${digest}`;
|
|
264
|
-
} catch {
|
|
265
|
-
return `${base}|codex-pool:unavailable`;
|
|
266
|
-
}
|
|
267
|
-
})();
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function publicCapacityWindow(window: import("./codex-capacity").CodexCapacityWindowAggregation) {
|
|
271
|
-
const { totalWeight: _totalWeight, consumedWeight: _consumedWeight, remainingWeight: _remainingWeight, ...safe } = window;
|
|
272
|
-
return safe;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/** Management API metadata intentionally omits configured/weighted unit counts. */
|
|
276
|
-
function publicCapacityAggregation(
|
|
277
|
-
aggregation: CodexCapacityAggregation,
|
|
278
|
-
presentation: NonNullable<CodexCapacityAggregation["presentation"]>,
|
|
279
|
-
): CodexCapacityAggregation {
|
|
280
|
-
const safeCurrentAccount = presentation === "coverage-only" && aggregation.currentAccount
|
|
281
|
-
? { ...aggregation.currentAccount, quota: null }
|
|
282
|
-
: aggregation.currentAccount;
|
|
283
|
-
return {
|
|
284
|
-
...aggregation,
|
|
285
|
-
presentation,
|
|
286
|
-
...(safeCurrentAccount ? { currentAccount: safeCurrentAccount } : {}),
|
|
287
|
-
...(aggregation.fiveHour ? { fiveHour: publicCapacityWindow(aggregation.fiveHour) } : {}),
|
|
288
|
-
...(aggregation.weekly ? { weekly: publicCapacityWindow(aggregation.weekly) } : {}),
|
|
289
|
-
...(aggregation.monthly ? { monthly: publicCapacityWindow(aggregation.monthly) } : {}),
|
|
290
|
-
...(aggregation.customWindows ? {
|
|
291
|
-
customWindows: aggregation.customWindows.map(window => ({
|
|
292
|
-
label: window.label,
|
|
293
|
-
...publicCapacityWindow(window),
|
|
294
|
-
})),
|
|
295
|
-
} : {}),
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function hasQuotaRows(quota: ProviderQuota | null | undefined): quota is ProviderQuota {
|
|
300
|
-
if (!quota) return false;
|
|
301
|
-
return typeof quota.fiveHourPercent === "number"
|
|
302
|
-
|| typeof quota.weeklyPercent === "number"
|
|
303
|
-
|| typeof quota.monthlyPercent === "number"
|
|
304
|
-
|| quota.creditsUsd?.unlimited === true
|
|
305
|
-
|| typeof quota.creditsUsd?.percent === "number"
|
|
306
|
-
|| !!quota.customWindows?.some(window => typeof window.percent === "number");
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function providerLabel(providerId: string): string {
|
|
310
|
-
return getProviderRegistryEntry(providerId)?.label ?? providerId;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** Test-only access to the quota reader's deadline and cancellation contract. */
|
|
314
|
-
export async function readProviderQuotaJsonForTests(response: Response, timeoutMs: number): Promise<unknown> {
|
|
315
|
-
const result = await readQuotaJson(response, timeoutMs);
|
|
316
|
-
return result === QUOTA_JSON_READ_FAILURE ? null : result;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function isBuiltInChatGptForwardProvider(name: string, provider: OcxProviderConfig): boolean {
|
|
320
|
-
return name === OPENAI_CODEX_PROVIDER_ID && isCanonicalOpenAiForwardProvider(provider);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function isCanonicalA6apiBaseUrl(baseUrl: string): boolean {
|
|
324
|
-
const normalized = normalizedBaseUrl(baseUrl);
|
|
325
|
-
return normalized === A6API_BASE_URL || normalized === `${A6API_BASE_URL}/v1`;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function isCanonicalOpenCodeGoBaseUrl(baseUrl: string): boolean {
|
|
329
|
-
return normalizedBaseUrl(baseUrl) === OPENCODE_GO_BASE_URL;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function isCanonicalOpenRouterBaseUrl(baseUrl: string): boolean {
|
|
333
|
-
const normalized = normalizedBaseUrl(baseUrl);
|
|
334
|
-
return normalized === OPENROUTER_BASE_URL;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function isCanonicalDeepSeekBaseUrl(baseUrl: string): boolean {
|
|
338
|
-
const normalized = normalizedBaseUrl(baseUrl);
|
|
339
|
-
return normalized === DEEPSEEK_BASE_URL || normalized === `${DEEPSEEK_BASE_URL}/v1`;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function isCanonicalClineBaseUrl(baseUrl: string): boolean {
|
|
343
|
-
const normalized = normalizedBaseUrl(baseUrl);
|
|
344
|
-
return normalized === CLINE_BASE_URL || normalized === `${CLINE_BASE_URL}/api/v1`;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function isCanonicalOllamaCloudBaseUrl(baseUrl?: string): boolean {
|
|
348
|
-
if (!baseUrl) return false;
|
|
349
|
-
try {
|
|
350
|
-
return isCanonicalOllamaCloudUrl(baseUrl);
|
|
351
|
-
} catch {
|
|
352
|
-
return false;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function zaiQuotaMonitorHost(baseUrl: string): string | null {
|
|
357
|
-
// Admission and destination selection must share one mapping: admitting a new
|
|
358
|
-
// international wire must never fall through to the CN host/authentication scheme.
|
|
359
|
-
switch (normalizedBaseUrl(baseUrl)) {
|
|
360
|
-
case ZAI_BASE_URL:
|
|
361
|
-
case `${ZAI_BASE_URL}/api/coding/paas/v4`:
|
|
362
|
-
case `${ZAI_BASE_URL}/api/anthropic`:
|
|
363
|
-
case `${ZAI_BASE_URL}/api/v1`:
|
|
364
|
-
return ZAI_BASE_URL;
|
|
365
|
-
case ZAI_CN_BASE_URL:
|
|
366
|
-
case `${ZAI_CN_BASE_URL}/api/coding/paas/v4`:
|
|
367
|
-
case `${ZAI_CN_BASE_URL}/api/v1`:
|
|
368
|
-
return ZAI_CN_BASE_URL;
|
|
369
|
-
default:
|
|
370
|
-
return null;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function isCanonicalZaiBaseUrl(baseUrl: string): boolean {
|
|
375
|
-
return zaiQuotaMonitorHost(baseUrl) !== null;
|
|
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 = resolveProviderApiKey(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
|
-
// Every row is an API-credit constraint on inference, so the display quota is also
|
|
459
|
-
// the routing projection. Passing it explicitly is the opt-in.
|
|
460
|
-
const quota: ProviderQuota = {
|
|
461
|
-
creditsUsd: {
|
|
462
|
-
used: 0,
|
|
463
|
-
limit: 0,
|
|
464
|
-
remaining: 0,
|
|
465
|
-
percent: 0,
|
|
466
|
-
unlimited: true,
|
|
467
|
-
...expiry,
|
|
468
|
-
},
|
|
469
|
-
customWindows: [{ label: "Unlimited API credits", percent: 0 }],
|
|
470
|
-
updatedAt: Date.now(),
|
|
471
|
-
};
|
|
472
|
-
return keyReport(provider, "a6api:billing", quota, config, apiKey, quota);
|
|
473
|
-
}
|
|
474
|
-
const limitUsd = firstFinite(subscription, ["hard_limit_usd"]);
|
|
475
|
-
const grantedUnits = firstFinite(token, ["total_granted"]);
|
|
476
|
-
const usedUnits = firstFinite(token, ["total_used"]);
|
|
477
|
-
const availableUnits = firstFinite(token, ["total_available"]);
|
|
478
|
-
const reconciledUnits = usedUnits !== undefined && availableUnits !== undefined
|
|
479
|
-
? usedUnits + availableUnits
|
|
480
|
-
: undefined;
|
|
481
|
-
const reconciliationTolerance = grantedUnits !== undefined
|
|
482
|
-
? Math.abs(grantedUnits) * 1e-9
|
|
483
|
-
: 0;
|
|
484
|
-
if (limitUsd === undefined || grantedUnits === undefined || usedUnits === undefined
|
|
485
|
-
|| availableUnits === undefined || limitUsd <= 0 || grantedUnits <= 0
|
|
486
|
-
|| usedUnits < 0 || availableUnits < 0
|
|
487
|
-
|| reconciledUnits === undefined
|
|
488
|
-
|| Math.abs(reconciledUnits - grantedUnits) > reconciliationTolerance) return TERMINAL_QUOTA_FAILURE;
|
|
489
|
-
const usdPerUnit = limitUsd / grantedUnits;
|
|
490
|
-
const usedUsd = usedUnits * usdPerUnit;
|
|
491
|
-
const remainingUsd = Math.max(0, availableUnits * usdPerUnit);
|
|
492
|
-
const percent = normalizePercent((usedUsd / limitUsd) * 100);
|
|
493
|
-
if (percent === undefined) return TERMINAL_QUOTA_FAILURE;
|
|
494
|
-
const label = `API credits ($${remainingUsd.toFixed(2)} of $${limitUsd.toFixed(2)} remaining)`;
|
|
495
|
-
const quota: ProviderQuota = {
|
|
496
|
-
creditsUsd: {
|
|
497
|
-
used: usedUsd,
|
|
498
|
-
limit: limitUsd,
|
|
499
|
-
remaining: remainingUsd,
|
|
500
|
-
percent,
|
|
501
|
-
...expiry,
|
|
502
|
-
},
|
|
503
|
-
customWindows: [{ label, percent }],
|
|
504
|
-
updatedAt: Date.now(),
|
|
505
|
-
};
|
|
506
|
-
// The credit balance funds inference itself, so display and routing scope agree.
|
|
507
|
-
return keyReport(provider, "a6api:billing", quota, config, apiKey, quota);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
function parseOpenCodeGoUsageWindow(value: unknown): { percent: number; resetAt?: number } | null {
|
|
511
|
-
const row = asRecord(value);
|
|
512
|
-
if (!row) return null;
|
|
513
|
-
const percent = normalizePercent(row.percent);
|
|
514
|
-
if (percent === undefined) return null;
|
|
515
|
-
const resetAt = normalizeResetAt(row.resetsAt);
|
|
516
|
-
return { percent, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
async function fetchOpenCodeGoQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
520
|
-
// Never send a configured API key when the provider destination is not the built-in Go endpoint.
|
|
521
|
-
if (!isCanonicalOpenCodeGoBaseUrl(config.baseUrl)) return null;
|
|
522
|
-
const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
|
|
523
|
-
if (!apiKey) return null;
|
|
524
|
-
const response = await fetch(OPENCODE_GO_USAGE_URL, {
|
|
525
|
-
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
526
|
-
redirect: "error",
|
|
527
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
528
|
-
});
|
|
529
|
-
if (!response.ok) {
|
|
530
|
-
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
531
|
-
? TERMINAL_QUOTA_FAILURE
|
|
532
|
-
: null;
|
|
533
|
-
}
|
|
534
|
-
const body = asRecord(await readQuotaJson(response));
|
|
535
|
-
const usage = asRecord(body?.usage);
|
|
536
|
-
if (!usage) return null;
|
|
537
|
-
const rolling = parseOpenCodeGoUsageWindow(usage.rolling);
|
|
538
|
-
const weekly = parseOpenCodeGoUsageWindow(usage.weekly);
|
|
539
|
-
const monthly = parseOpenCodeGoUsageWindow(usage.monthly);
|
|
540
|
-
const quota: ProviderQuota = {
|
|
541
|
-
...(rolling ? {
|
|
542
|
-
fiveHourPercent: rolling.percent,
|
|
543
|
-
...(rolling.resetAt !== undefined ? { fiveHourResetAt: rolling.resetAt } : {}),
|
|
544
|
-
} : {}),
|
|
545
|
-
...(weekly ? {
|
|
546
|
-
weeklyPercent: weekly.percent,
|
|
547
|
-
...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}),
|
|
548
|
-
} : {}),
|
|
549
|
-
...(monthly ? {
|
|
550
|
-
monthlyPercent: monthly.percent,
|
|
551
|
-
...(monthly.resetAt !== undefined ? { monthlyResetAt: monthly.resetAt } : {}),
|
|
552
|
-
} : {}),
|
|
553
|
-
updatedAt: Date.now(),
|
|
554
|
-
};
|
|
555
|
-
return keyReport(provider, "opencode-go:usage", quota, config, apiKey, quota);
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* OpenRouter `GET /api/v1/key` — the key's own credit balance and optional
|
|
560
|
-
* per-key spending cap. `limit` is the configured cap (absent = uncapped);
|
|
561
|
-
* `usage` is lifetime spend; `limit_remaining` is what is left of the cap.
|
|
562
|
-
* When no cap is set there is no hard limit to meter against, so no bar is
|
|
563
|
-
* produced — the provider falls back to its documented reference.
|
|
564
|
-
*/
|
|
565
|
-
async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
566
|
-
// Never send a configured API key to a lookalike host or through a redirect.
|
|
567
|
-
if (!isCanonicalOpenRouterBaseUrl(config.baseUrl)) return null;
|
|
568
|
-
const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
|
|
569
|
-
if (!apiKey) return null;
|
|
570
|
-
const response = await fetch(`${OPENROUTER_BASE_URL}/key`, {
|
|
571
|
-
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
572
|
-
redirect: "error",
|
|
573
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
574
|
-
});
|
|
575
|
-
if (!response.ok) {
|
|
576
|
-
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
577
|
-
? TERMINAL_QUOTA_FAILURE
|
|
578
|
-
: null;
|
|
579
|
-
}
|
|
580
|
-
const body = asRecord(await readQuotaJson(response));
|
|
581
|
-
const data = asRecord(body?.data) ?? body;
|
|
582
|
-
if (!data) return null;
|
|
583
|
-
const limit = toFiniteNumber(data.limit);
|
|
584
|
-
const limitRemaining = toFiniteNumber(data.limit_remaining);
|
|
585
|
-
const usage = toFiniteNumber(data.usage);
|
|
586
|
-
// A successful no-cap response is a DELIBERATE change, not a transient
|
|
587
|
-
// failure: the old capped row must be dropped, not preserved as last-good.
|
|
588
|
-
if (limit === undefined || limit <= 0) return TERMINAL_QUOTA_FAILURE;
|
|
589
|
-
// Prefer the authoritative remaining-cap value when present: `usage` is
|
|
590
|
-
// lifetime accumulated spend and overstates a reset or re-capped key.
|
|
591
|
-
const used = limitRemaining !== undefined
|
|
592
|
-
? Math.max(0, limit - limitRemaining)
|
|
593
|
-
: usage !== undefined && usage >= 0 ? usage : undefined;
|
|
594
|
-
if (used === undefined) return null;
|
|
595
|
-
const percent = normalizePercent((used / limit) * 100);
|
|
596
|
-
if (percent === undefined) return null;
|
|
597
|
-
const remaining = Math.max(0, limit - used);
|
|
598
|
-
const label = `API credits ($${remaining.toFixed(2)} of $${limit.toFixed(2)} remaining)`;
|
|
599
|
-
// The per-key spending cap stops every request this credential can make, so the
|
|
600
|
-
// whole report is inference-wide routing evidence.
|
|
601
|
-
const quota: ProviderQuota = {
|
|
602
|
-
customWindows: [{ label, percent }],
|
|
603
|
-
updatedAt: Date.now(),
|
|
604
|
-
};
|
|
605
|
-
return keyReport(provider, "openrouter:key-info", quota, config, apiKey, quota);
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
/**
|
|
609
|
-
* DeepSeek `GET /user/balance` — the account's granted + topped-up credit
|
|
610
|
-
* balance. The payload places `total_balance` / `granted_balance` inside
|
|
611
|
-
* entries of `balance_infos` (one row per currency); the row for the account's
|
|
612
|
-
* currency is selected by preference. `granted_balance` is a CURRENT balance
|
|
613
|
-
* component, not the original grant ceiling, so no consumed percentage is
|
|
614
|
-
* fabricated — the balance is reported as a balance-only window.
|
|
615
|
-
*/
|
|
616
|
-
async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
617
|
-
if (!isCanonicalDeepSeekBaseUrl(config.baseUrl)) return null;
|
|
618
|
-
const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
|
|
619
|
-
if (!apiKey) return null;
|
|
620
|
-
const response = await fetch(`${DEEPSEEK_BASE_URL}/user/balance`, {
|
|
621
|
-
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
622
|
-
redirect: "error",
|
|
623
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
624
|
-
});
|
|
625
|
-
if (!response.ok) {
|
|
626
|
-
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
627
|
-
? TERMINAL_QUOTA_FAILURE
|
|
628
|
-
: null;
|
|
629
|
-
}
|
|
630
|
-
const body = asRecord(await readQuotaJson(response));
|
|
631
|
-
// The payload nests balances under `balance_infos` rows keyed by currency;
|
|
632
|
-
// prefer a USD row, then CNY, then the first row that parses.
|
|
633
|
-
const infos = Array.isArray(body?.balance_infos) ? body.balance_infos as unknown[] : null;
|
|
634
|
-
const rows = infos
|
|
635
|
-
? infos.map((raw): Record<string, unknown> | null => asRecord(raw)).filter((r): r is Record<string, unknown> => r !== null)
|
|
636
|
-
: [];
|
|
637
|
-
const pick = (currency: string): Record<string, unknown> | null =>
|
|
638
|
-
rows.find(row => String(row.currency ?? "").toUpperCase() === currency) ?? null;
|
|
639
|
-
const preferred = pick("USD") ?? pick("CNY") ?? rows[0] ?? null;
|
|
640
|
-
if (!preferred) return null;
|
|
641
|
-
const totalBalance = toFiniteNumber(preferred.total_balance);
|
|
642
|
-
const grantedBalance = toFiniteNumber(preferred.granted_balance);
|
|
643
|
-
const toppedUp = toFiniteNumber(preferred.topped_up_balance);
|
|
644
|
-
const balance = totalBalance ?? grantedBalance ?? toppedUp;
|
|
645
|
-
if (balance === undefined || balance < 0) return null;
|
|
646
|
-
const label = grantedBalance !== undefined && grantedBalance > 0
|
|
647
|
-
? `API balance ($${balance.toFixed(2)} total, $${grantedBalance.toFixed(2)} granted)`
|
|
648
|
-
: `API balance ($${balance.toFixed(2)})`;
|
|
649
|
-
return report(provider, "deepseek:balance", {
|
|
650
|
-
customWindows: [{ label, percent: 0 }],
|
|
651
|
-
updatedAt: Date.now(),
|
|
652
|
-
});
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
/**
|
|
656
|
-
* ClinePass `GET /api/v1/users/me/plan/usage-limits` — the subscription's
|
|
657
|
-
* rolling five-hour, weekly, and monthly utilization, matching the existing
|
|
658
|
-
* ProviderQuota windows directly. The endpoint 404s (or returns a null plan)
|
|
659
|
-
* for accounts without an active ClinePass, which is a no-report, not an error.
|
|
660
|
-
*/
|
|
661
|
-
async function fetchClineQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
662
|
-
if (!isCanonicalClineBaseUrl(config.baseUrl)) return null;
|
|
663
|
-
const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
|
|
664
|
-
if (!apiKey) return null;
|
|
665
|
-
const response = await fetch(`${CLINE_BASE_URL}/api/v1/users/me/plan/usage-limits`, {
|
|
666
|
-
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
667
|
-
redirect: "error",
|
|
668
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
669
|
-
});
|
|
670
|
-
if (!response.ok) {
|
|
671
|
-
// 404 = no active plan; a plain "no plan" is a no-report, everything else
|
|
672
|
-
// 4xx (except 408/429) is a credential/contract problem.
|
|
673
|
-
if (response.status === 404) return null;
|
|
674
|
-
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
675
|
-
? TERMINAL_QUOTA_FAILURE
|
|
676
|
-
: null;
|
|
677
|
-
}
|
|
678
|
-
const body = asRecord(await readQuotaJson(response));
|
|
679
|
-
const data = asRecord(body?.data) ?? body;
|
|
680
|
-
const limits = Array.isArray(data?.limits) ? data.limits : null;
|
|
681
|
-
if (!limits) return null;
|
|
682
|
-
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
683
|
-
let windows = 0;
|
|
684
|
-
for (const raw of limits) {
|
|
685
|
-
const row = asRecord(raw);
|
|
686
|
-
if (!row) continue;
|
|
687
|
-
const percent = normalizePercent(row.percentUsed);
|
|
688
|
-
if (percent === undefined) continue;
|
|
689
|
-
const resetAt = normalizeResetAt(row.resetsAt);
|
|
690
|
-
if (row.type === "five_hour") {
|
|
691
|
-
quota.fiveHourPercent = percent;
|
|
692
|
-
if (resetAt !== undefined) quota.fiveHourResetAt = resetAt;
|
|
693
|
-
windows += 1;
|
|
694
|
-
} else if (row.type === "weekly") {
|
|
695
|
-
quota.weeklyPercent = percent;
|
|
696
|
-
if (resetAt !== undefined) quota.weeklyResetAt = resetAt;
|
|
697
|
-
windows += 1;
|
|
698
|
-
} else if (row.type === "monthly") {
|
|
699
|
-
quota.monthlyPercent = percent;
|
|
700
|
-
if (resetAt !== undefined) quota.monthlyResetAt = resetAt;
|
|
701
|
-
windows += 1;
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
return windows > 0 ? keyReport(provider, "cline:plan-usage-limits", quota, config, apiKey, quota) : null;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
/**
|
|
708
|
-
* Ollama Cloud `GET https://ollama.com/api/usage` — returns account usage.
|
|
709
|
-
* Legacy plans report rolling 5-hour `limits.session.usage` and 7-day
|
|
710
|
-
* `limits.weekly.usage`. Migrated monthly-credit plans report
|
|
711
|
-
* `limits.monthly.usage`. `usage` values are normalized fractions (0..1).
|
|
712
|
-
*/
|
|
713
|
-
function parseOllamaPercent(usageValue: unknown): number | undefined {
|
|
714
|
-
const usage = toFiniteNumber(usageValue);
|
|
715
|
-
if (usage === undefined || usage < 0) return undefined;
|
|
716
|
-
const percent = Math.round(usage * 10000) / 100;
|
|
717
|
-
return normalizePercent(percent);
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
export function parseOllamaCloudQuota(body: Record<string, unknown> | null): ProviderQuota | null {
|
|
721
|
-
if (!body) return null;
|
|
722
|
-
const limits = asRecord(body.limits);
|
|
723
|
-
if (!limits) return null;
|
|
724
|
-
|
|
725
|
-
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
726
|
-
let windows = 0;
|
|
727
|
-
|
|
728
|
-
const session = asRecord(limits.session);
|
|
729
|
-
if (session) {
|
|
730
|
-
const percent = parseOllamaPercent(session.usage);
|
|
731
|
-
if (percent !== undefined) {
|
|
732
|
-
quota.fiveHourPercent = percent;
|
|
733
|
-
windows += 1;
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
const weekly = asRecord(limits.weekly);
|
|
738
|
-
if (weekly) {
|
|
739
|
-
const percent = parseOllamaPercent(weekly.usage);
|
|
740
|
-
if (percent !== undefined) {
|
|
741
|
-
quota.weeklyPercent = percent;
|
|
742
|
-
windows += 1;
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
const monthly = asRecord(limits.monthly);
|
|
747
|
-
if (monthly) {
|
|
748
|
-
const percent = parseOllamaPercent(monthly.usage);
|
|
749
|
-
if (percent !== undefined) {
|
|
750
|
-
quota.monthlyPercent = percent;
|
|
751
|
-
windows += 1;
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
return windows > 0 ? quota : null;
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
async function fetchOllamaCloudQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
759
|
-
const effectiveBaseUrl = config.baseUrl ?? getProviderRegistryEntry(provider)?.baseUrl ?? "";
|
|
760
|
-
if (!isCanonicalOllamaCloudBaseUrl(effectiveBaseUrl)) return null;
|
|
761
|
-
const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
|
|
762
|
-
if (!apiKey) return null;
|
|
763
|
-
const response = await fetch(OLLAMA_CLOUD_USAGE_URL, {
|
|
764
|
-
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
765
|
-
redirect: "error",
|
|
766
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
767
|
-
});
|
|
768
|
-
if (!response.ok) {
|
|
769
|
-
if (response.status === 404) return null;
|
|
770
|
-
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
771
|
-
? TERMINAL_QUOTA_FAILURE
|
|
772
|
-
: null;
|
|
773
|
-
}
|
|
774
|
-
const body = asRecord(await readQuotaJson(response));
|
|
775
|
-
const quota = parseOllamaCloudQuota(body);
|
|
776
|
-
return quota ? keyReport(provider, "ollama-cloud:usage", quota, config, apiKey, quota) : null;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
/**
|
|
780
|
-
* Z.AI GLM Coding Plan `GET /api/monitor/usage/quota/limit` — the coding-plan
|
|
781
|
-
* limits arrive as a `limits` array of `TOKENS_LIMIT` (newer plans call the
|
|
782
|
-
* same rows `CREDIT_LIMIT`) and `TIME_LIMIT` rows. `TOKENS_LIMIT`/`CREDIT_LIMIT`
|
|
783
|
-
* rows carry the window length as `unit`/`number`: unit 3 is hours (number 5 →
|
|
784
|
-
* the rolling five-hour window), unit 6 is weeks (number 1 → the weekly
|
|
785
|
-
* window). Every row's `percentage` is the consumed share (falling
|
|
786
|
-
* back to `currentValue`/`usage` when absent) and `nextResetTime` (unix ms)
|
|
787
|
-
* the window reset.
|
|
788
|
-
*
|
|
789
|
-
* `TIME_LIMIT` rows are deliberately ignored (issue #1168). They are the shared
|
|
790
|
-
* monthly MCP *call* allowance for Web Search / Web Reader / Zread — not a
|
|
791
|
-
* model-token budget — and `ProviderQuota.monthlyPercent` is consumed as a
|
|
792
|
-
* model-capacity signal: `headroomOf()` in `src/oauth/account-quota-rank.ts`
|
|
793
|
-
* takes the MAX across every window, so a user who spent their MCP search
|
|
794
|
-
* allowance would be ranked as having no model capacity left, and the dashboard
|
|
795
|
-
* would draw a full monthly bar for a plan whose model tokens are untouched.
|
|
796
|
-
* A payload carrying only `TIME_LIMIT` rows therefore reports no quota at all,
|
|
797
|
-
* which is the honest answer rather than a fabricated one.
|
|
798
|
-
*/
|
|
799
|
-
export function parseZaiQuotaLimits(data: Record<string, unknown> | null): ProviderQuota | null {
|
|
800
|
-
const limits = Array.isArray(data?.limits) ? data.limits as unknown[] : null;
|
|
801
|
-
if (!limits) return null;
|
|
802
|
-
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
803
|
-
let windows = 0;
|
|
804
|
-
for (const raw of limits) {
|
|
805
|
-
const row = asRecord(raw);
|
|
806
|
-
if (!row) continue;
|
|
807
|
-
// Gate on row type before deriving a percentage: an MCP row must not even
|
|
808
|
-
// contribute a parsed value to a model-quota report.
|
|
809
|
-
if (row.type !== "TOKENS_LIMIT" && row.type !== "CREDIT_LIMIT") continue;
|
|
810
|
-
const resetAt = normalizeResetAt(row.nextResetTime);
|
|
811
|
-
let percent = normalizePercent(row.percentage);
|
|
812
|
-
if (percent === undefined) {
|
|
813
|
-
const used = toFiniteNumber(row.currentValue);
|
|
814
|
-
const total = toFiniteNumber(row.usage);
|
|
815
|
-
if (used !== undefined && total !== undefined && total > 0) {
|
|
816
|
-
percent = normalizePercent((used / total) * 100);
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
if (percent === undefined) continue;
|
|
820
|
-
const unit = toFiniteNumber(row.unit);
|
|
821
|
-
const number = toFiniteNumber(row.number);
|
|
822
|
-
if (unit === 3 && number === 5) {
|
|
823
|
-
quota.fiveHourPercent = percent;
|
|
824
|
-
if (resetAt !== undefined) quota.fiveHourResetAt = resetAt;
|
|
825
|
-
windows += 1;
|
|
826
|
-
} else if (unit === 6 && number === 1) {
|
|
827
|
-
quota.weeklyPercent = percent;
|
|
828
|
-
if (resetAt !== undefined) quota.weeklyResetAt = resetAt;
|
|
829
|
-
windows += 1;
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
return windows > 0 ? quota : null;
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
/**
|
|
836
|
-
* Legacy Z.AI payload shape: percent fields with window identifiers directly on
|
|
837
|
-
* the data object (optionally nested under `quota`). Kept as a fallback so
|
|
838
|
-
* older responses keep rendering when the `limits` array is absent.
|
|
839
|
-
*/
|
|
840
|
-
function parseZaiQuotaLegacyFields(data: Record<string, unknown> | null): ProviderQuota | null {
|
|
841
|
-
if (!data) return null;
|
|
842
|
-
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
843
|
-
let windows = 0;
|
|
844
|
-
const percentAt = (key: string): number | undefined => {
|
|
845
|
-
const value = normalizePercent(data[key]);
|
|
846
|
-
if (value !== undefined) return value;
|
|
847
|
-
const nested = asRecord(data.quota);
|
|
848
|
-
return nested ? normalizePercent(nested[key]) : undefined;
|
|
849
|
-
};
|
|
850
|
-
const fiveHour = percentAt("fiveHourPercent") ?? percentAt("fiveHourUsage") ?? percentAt("fiveHourUsed");
|
|
851
|
-
const weekly = percentAt("weeklyPercent") ?? percentAt("weeklyUsage") ?? percentAt("weeklyUsed");
|
|
852
|
-
const monthly = percentAt("monthlyPercent") ?? percentAt("mcpPercent") ?? percentAt("monthlyMCPUsage");
|
|
853
|
-
if (fiveHour !== undefined) {
|
|
854
|
-
quota.fiveHourPercent = fiveHour;
|
|
855
|
-
windows += 1;
|
|
856
|
-
}
|
|
857
|
-
if (weekly !== undefined) {
|
|
858
|
-
quota.weeklyPercent = weekly;
|
|
859
|
-
windows += 1;
|
|
860
|
-
}
|
|
861
|
-
if (monthly !== undefined) {
|
|
862
|
-
quota.monthlyPercent = monthly;
|
|
863
|
-
windows += 1;
|
|
864
|
-
}
|
|
865
|
-
return windows > 0 ? quota : null;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
/**
|
|
869
|
-
* Fetches the Z.AI GLM Coding Plan quota — on whichever region the provider
|
|
870
|
-
* points at (api.z.ai or open.bigmodel.cn). The `limits` array shape is
|
|
871
|
-
* preferred; older field-name payloads fall back to the legacy parser.
|
|
872
|
-
*
|
|
873
|
-
* Authentication differs by host (issue #1168). `api.z.ai` takes the API key as
|
|
874
|
-
* a Bearer token per Z.AI's API reference; `open.bigmodel.cn` expects the key
|
|
875
|
-
* directly in `Authorization` with no scheme prefix and answers a Bearer header
|
|
876
|
-
* with an auth error, which is why BigModel Coding Plan quota never rendered.
|
|
877
|
-
* The host is already canonicalized by `isCanonicalZaiBaseUrl` above and
|
|
878
|
-
* `redirect: "error"` stays set, so the bare key cannot travel to a lookalike
|
|
879
|
-
* host or follow a redirect off-origin.
|
|
880
|
-
*/
|
|
881
|
-
async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
882
|
-
const monitorHost = zaiQuotaMonitorHost(config.baseUrl);
|
|
883
|
-
if (!monitorHost) return null;
|
|
884
|
-
const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
|
|
885
|
-
if (!apiKey) return null;
|
|
886
|
-
const authorization = monitorHost === ZAI_CN_BASE_URL ? apiKey : `Bearer ${apiKey}`;
|
|
887
|
-
const response = await fetch(`${monitorHost}/api/monitor/usage/quota/limit`, {
|
|
888
|
-
headers: { Accept: "application/json", Authorization: authorization },
|
|
889
|
-
redirect: "error",
|
|
890
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
891
|
-
});
|
|
892
|
-
if (!response.ok) {
|
|
893
|
-
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
894
|
-
? TERMINAL_QUOTA_FAILURE
|
|
895
|
-
: null;
|
|
896
|
-
}
|
|
897
|
-
const body = asRecord(await readQuotaJson(response));
|
|
898
|
-
if (!body || body.success === false) return null;
|
|
899
|
-
const data = asRecord(body.data) ?? body;
|
|
900
|
-
if (Array.isArray(data?.limits)) {
|
|
901
|
-
const quota = parseZaiQuotaLimits(data);
|
|
902
|
-
// A well-formed `limits[]` we fully understood is authoritative even when it yields no
|
|
903
|
-
// model window — for example a plan reporting only the monthly MCP `TIME_LIMIT` row.
|
|
904
|
-
// Returning `null` here would preserve the previous token windows for up to 30 minutes
|
|
905
|
-
// and keep quota-aware routing acting on a report the provider has already superseded.
|
|
906
|
-
return quota
|
|
907
|
-
? keyReport(provider, "zai:quota-limit", quota, config, apiKey, quota)
|
|
908
|
-
: AUTHORITATIVE_EMPTY_QUOTA;
|
|
909
|
-
}
|
|
910
|
-
const legacy = parseZaiQuotaLegacyFields(data);
|
|
911
|
-
if (!legacy) return null;
|
|
912
|
-
// The legacy monthly figure also carries MCP usage; it is display evidence, not
|
|
913
|
-
// proof that model inference is unavailable. Modern TOKEN_LIMIT rows above are scoped.
|
|
914
|
-
const inferenceQuota = { ...legacy };
|
|
915
|
-
delete inferenceQuota.monthlyPercent;
|
|
916
|
-
delete inferenceQuota.monthlyResetAt;
|
|
917
|
-
return keyReport(provider, "zai:quota-limit", legacy, config, apiKey, inferenceQuota);
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
/**
|
|
921
|
-
* MiniMax Token Plan `GET /v1/token_plan/remains` — the subscription's
|
|
922
|
-
* remaining quota as a countdown-time value (ms). The endpoint does not expose
|
|
923
|
-
* the plan's total duration, so no percentage is fabricated from a presumed
|
|
924
|
-
* window: the remaining time is reported as a duration-only window. When the
|
|
925
|
-
* API supplies a total (`total_time` / `plan_duration_ms`), a consumed share
|
|
926
|
-
* is derived from it. Region selects the host: `minimax` → www.minimax.io,
|
|
927
|
-
* `minimax-cn` → api.minimaxi.com.
|
|
928
|
-
*/
|
|
929
|
-
async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
930
|
-
if (!isCanonicalMinimaxBaseUrl(config.baseUrl)) return null;
|
|
931
|
-
const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
|
|
932
|
-
if (!apiKey) return null;
|
|
933
|
-
const cnHost = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.minimaxi.com");
|
|
934
|
-
const remainsUrl = cnHost ? "https://api.minimaxi.com/v1/token_plan/remains" : MINIMAX_REMAINS_URL;
|
|
935
|
-
const response = await fetch(remainsUrl, {
|
|
936
|
-
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
937
|
-
redirect: "error",
|
|
938
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
939
|
-
});
|
|
940
|
-
if (!response.ok) {
|
|
941
|
-
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
942
|
-
? TERMINAL_QUOTA_FAILURE
|
|
943
|
-
: null;
|
|
944
|
-
}
|
|
945
|
-
const body = asRecord(await readQuotaJson(response));
|
|
946
|
-
if (!body || body.success === false) return null;
|
|
947
|
-
const data = asRecord(body.data) ?? body;
|
|
948
|
-
const remainsMs = toFiniteNumber(data.remains_time ?? data.remainsTime);
|
|
949
|
-
if (remainsMs === undefined || remainsMs < 0) return null;
|
|
950
|
-
const hours = Math.floor(remainsMs / 3_600_000);
|
|
951
|
-
const label = `Token Plan remaining (${hours}h)`;
|
|
952
|
-
// Only derive a consumed share when the API actually reports the plan total;
|
|
953
|
-
// a presumed window (e.g. 30 days) would fabricate utilization. A valid
|
|
954
|
-
// response that omits the total after a prior refresh had it is a DELIBERATE
|
|
955
|
-
// contract change — the old row must be dropped (terminal), not preserved as
|
|
956
|
-
// a transient last-good.
|
|
957
|
-
const totalMs = toFiniteNumber(data.total_time ?? data.plan_duration_ms ?? data.total_duration_ms);
|
|
958
|
-
if (totalMs === undefined || totalMs <= 0) return TERMINAL_QUOTA_FAILURE;
|
|
959
|
-
const consumed = Math.max(0, totalMs - remainsMs);
|
|
960
|
-
const percent = normalizePercent((consumed / totalMs) * 100);
|
|
961
|
-
if (percent === undefined) return null;
|
|
962
|
-
return report(provider, "minimax:token-plan-remains", {
|
|
963
|
-
customWindows: [{ label, percent }],
|
|
964
|
-
updatedAt: Date.now(),
|
|
965
|
-
});
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
/**
|
|
969
|
-
* Moonshot/Kimi `GET /v1/users/me/balance` — the account's available balance
|
|
970
|
-
* (voucher + cash). Renders a single balance window against the sum of
|
|
971
|
-
* voucher + cash when positive (there is no per-window rate limit to meter).
|
|
972
|
-
*/
|
|
973
|
-
async function fetchMoonshotQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
974
|
-
if (!isCanonicalMoonshotBaseUrl(config.baseUrl)) return null;
|
|
975
|
-
const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
|
|
976
|
-
if (!apiKey) return null;
|
|
977
|
-
const host = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.moonshot.cn") ? "https://api.moonshot.cn/v1" : MOONSHOT_BASE_URL;
|
|
978
|
-
const response = await fetch(`${host}/users/me/balance`, {
|
|
979
|
-
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
980
|
-
redirect: "error",
|
|
981
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
982
|
-
});
|
|
983
|
-
if (!response.ok) {
|
|
984
|
-
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
985
|
-
? TERMINAL_QUOTA_FAILURE
|
|
986
|
-
: null;
|
|
987
|
-
}
|
|
988
|
-
const body = asRecord(await readQuotaJson(response));
|
|
989
|
-
const data = asRecord(body?.data) ?? body;
|
|
990
|
-
if (!data) return null;
|
|
991
|
-
const available = toFiniteNumber(data.available_balance);
|
|
992
|
-
const voucher = toFiniteNumber(data.voucher_balance);
|
|
993
|
-
const cash = toFiniteNumber(data.cash_balance);
|
|
994
|
-
if (available === undefined || available < 0) return null;
|
|
995
|
-
// Moonshot exposes no per-window quota ceiling, only a balance — report it
|
|
996
|
-
// as a balance-only window (percent 0) rather than a fabricated utilization.
|
|
997
|
-
// Currency is host-scoped: China platform (api.moonshot.cn) bills in CNY;
|
|
998
|
-
// the international platform (api.moonshot.ai) bills in USD. Do not force
|
|
999
|
-
// either side into the other unit — the number is correct, only the unit
|
|
1000
|
-
// must match the host.
|
|
1001
|
-
const isChinaHost = host.startsWith("https://api.moonshot.cn");
|
|
1002
|
-
const money = (n: number) => isChinaHost ? `¥${n.toFixed(2)}` : `$${n.toFixed(2)}`;
|
|
1003
|
-
const unit = isChinaHost ? "CNY" : "USD";
|
|
1004
|
-
const label = voucher !== undefined && cash !== undefined
|
|
1005
|
-
? `Balance (${money(available)} ${unit} available, ${money(voucher)} voucher)`
|
|
1006
|
-
: `Balance (${money(available)} ${unit} available)`;
|
|
1007
|
-
return report(provider, "moonshot:balance", {
|
|
1008
|
-
customWindows: [{ label, percent: 0 }],
|
|
1009
|
-
updatedAt: Date.now(),
|
|
1010
|
-
});
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
/**
|
|
1014
|
-
* Venice `GET /api/v1/billing/balance` — DIEM (native credits) or USD balance.
|
|
1015
|
-
* Shows the remaining balance; epoch allocation progress when present.
|
|
1016
|
-
*/
|
|
1017
|
-
async function fetchVeniceQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
1018
|
-
if (!isCanonicalVeniceBaseUrl(config.baseUrl)) return null;
|
|
1019
|
-
const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
|
|
1020
|
-
if (!apiKey) return null;
|
|
1021
|
-
const response = await fetch(`${VENICE_BASE_URL}/billing/balance`, {
|
|
1022
|
-
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
1023
|
-
redirect: "error",
|
|
1024
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1025
|
-
});
|
|
1026
|
-
if (!response.ok) {
|
|
1027
|
-
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
1028
|
-
? TERMINAL_QUOTA_FAILURE
|
|
1029
|
-
: null;
|
|
1030
|
-
}
|
|
1031
|
-
const body = asRecord(await readQuotaJson(response));
|
|
1032
|
-
const data = asRecord(body?.data) ?? body;
|
|
1033
|
-
if (!data) return null;
|
|
1034
|
-
const diemBalance = toFiniteNumber(data.balance);
|
|
1035
|
-
const usdBalance = toFiniteNumber(data.balance_usd);
|
|
1036
|
-
const epochUsed = toFiniteNumber(data.diem_epoch_used);
|
|
1037
|
-
const epochAllocated = toFiniteNumber(data.diem_epoch_allocated);
|
|
1038
|
-
if (diemBalance === undefined && usdBalance === undefined) return null;
|
|
1039
|
-
const label = diemBalance !== undefined
|
|
1040
|
-
? `DIEM balance (${Math.round(diemBalance)})`
|
|
1041
|
-
: `USD balance ($${usdBalance?.toFixed(2) ?? "?"})`;
|
|
1042
|
-
if (epochAllocated !== undefined && epochAllocated > 0 && epochUsed !== undefined) {
|
|
1043
|
-
const percent = normalizePercent((epochUsed / epochAllocated) * 100);
|
|
1044
|
-
if (percent === undefined) return null;
|
|
1045
|
-
return report(provider, "venice:billing-balance", {
|
|
1046
|
-
customWindows: [{ label, percent }],
|
|
1047
|
-
updatedAt: Date.now(),
|
|
1048
|
-
});
|
|
1049
|
-
}
|
|
1050
|
-
return report(provider, "venice:billing-balance", {
|
|
1051
|
-
customWindows: [{ label, percent: 0 }],
|
|
1052
|
-
updatedAt: Date.now(),
|
|
1053
|
-
});
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
/**
|
|
1057
|
-
* Synthetic `GET /v2/quotas` — the known quota lanes (rolling 5-hour,
|
|
1058
|
-
* weekly token, search-hourly) mapped onto the quota windows.
|
|
1059
|
-
*/
|
|
1060
|
-
async function fetchSyntheticQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
1061
|
-
if (!isCanonicalSyntheticBaseUrl(config.baseUrl)) return null;
|
|
1062
|
-
const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
|
|
1063
|
-
if (!apiKey) return null;
|
|
1064
|
-
const response = await fetch(`${SYNTHETIC_BASE_URL}/quotas`, {
|
|
1065
|
-
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
1066
|
-
redirect: "error",
|
|
1067
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1068
|
-
});
|
|
1069
|
-
if (!response.ok) {
|
|
1070
|
-
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
1071
|
-
? TERMINAL_QUOTA_FAILURE
|
|
1072
|
-
: null;
|
|
1073
|
-
}
|
|
1074
|
-
const body = asRecord(await readQuotaJson(response));
|
|
1075
|
-
const data = asRecord(body?.data) ?? body;
|
|
1076
|
-
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
1077
|
-
let windows = 0;
|
|
1078
|
-
const percentAt = (key: string): number | undefined => {
|
|
1079
|
-
const value = normalizePercent(data?.[key]);
|
|
1080
|
-
if (value !== undefined) return value;
|
|
1081
|
-
const nested = asRecord(data?.quota) ?? asRecord(data?.quotas);
|
|
1082
|
-
return nested ? normalizePercent(nested[key]) : undefined;
|
|
1083
|
-
};
|
|
1084
|
-
const fiveHour = percentAt("rollingFiveHourLimit");
|
|
1085
|
-
const weekly = percentAt("weeklyTokenLimit");
|
|
1086
|
-
if (fiveHour !== undefined) {
|
|
1087
|
-
quota.fiveHourPercent = fiveHour;
|
|
1088
|
-
windows += 1;
|
|
1089
|
-
}
|
|
1090
|
-
if (weekly !== undefined) {
|
|
1091
|
-
quota.weeklyPercent = weekly;
|
|
1092
|
-
windows += 1;
|
|
1093
|
-
}
|
|
1094
|
-
const search = asRecord(data?.search);
|
|
1095
|
-
const searchHourly = search ? normalizePercent(search.hourly) : undefined;
|
|
1096
|
-
if (searchHourly !== undefined) {
|
|
1097
|
-
quota.customWindows = [...(quota.customWindows ?? []), { label: "Search hourly", percent: searchHourly }];
|
|
1098
|
-
windows += 1;
|
|
1099
|
-
}
|
|
1100
|
-
const inferenceQuota = { ...quota };
|
|
1101
|
-
delete inferenceQuota.customWindows; // search.hourly does not constrain model inference.
|
|
1102
|
-
return windows > 0 ? keyReport(provider, "synthetic:quotas", quota, config, apiKey, inferenceQuota) : null;
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
/**
|
|
1106
|
-
* DeepInfra `GET /payment/checklist?compute_owed=true` — prepaid balance,
|
|
1107
|
-
* recent spend, spending limit, and suspension state. Renders a balance
|
|
1108
|
-
* window (prepaid funds are a negative `stripe_balance` → positive available).
|
|
1109
|
-
*/
|
|
1110
|
-
async function fetchDeepInfraQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
1111
|
-
if (!isCanonicalDeepInfraBaseUrl(config.baseUrl)) return null;
|
|
1112
|
-
const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
|
|
1113
|
-
if (!apiKey) return null;
|
|
1114
|
-
const response = await fetch(`${DEEPINFRA_BASE_URL}/payment/checklist?compute_owed=true`, {
|
|
1115
|
-
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
1116
|
-
redirect: "error",
|
|
1117
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1118
|
-
});
|
|
1119
|
-
if (!response.ok) {
|
|
1120
|
-
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
1121
|
-
? TERMINAL_QUOTA_FAILURE
|
|
1122
|
-
: null;
|
|
1123
|
-
}
|
|
1124
|
-
const body = asRecord(await readQuotaJson(response));
|
|
1125
|
-
const data = asRecord(body?.data) ?? body;
|
|
1126
|
-
if (!data) return null;
|
|
1127
|
-
const stripeBalance = toFiniteNumber(data.stripe_balance);
|
|
1128
|
-
const spendLimit = toFiniteNumber(data.spending_limit);
|
|
1129
|
-
const total = toFiniteNumber(data.total_amount_due);
|
|
1130
|
-
if (stripeBalance === undefined) return null;
|
|
1131
|
-
// Prepaid funds are negative; a positive value is money owed.
|
|
1132
|
-
const available = stripeBalance < 0 ? -stripeBalance : 0;
|
|
1133
|
-
if (spendLimit !== undefined && spendLimit > 0) {
|
|
1134
|
-
const spent = total !== undefined && total > 0 ? total : Math.max(0, spendLimit - available);
|
|
1135
|
-
const percent = normalizePercent((spent / spendLimit) * 100);
|
|
1136
|
-
if (percent === undefined) return null;
|
|
1137
|
-
return report(provider, "deepinfra:billing-checklist", {
|
|
1138
|
-
customWindows: [{ label: `Billing cycle spend ($${spent.toFixed(2)} of $${spendLimit.toFixed(2)})`, percent }],
|
|
1139
|
-
updatedAt: Date.now(),
|
|
1140
|
-
});
|
|
1141
|
-
}
|
|
1142
|
-
return report(provider, "deepinfra:billing-checklist", {
|
|
1143
|
-
customWindows: [{ label: `Prepaid balance ($${available.toFixed(2)})`, percent: 0 }],
|
|
1144
|
-
updatedAt: Date.now(),
|
|
1145
|
-
});
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
/**
|
|
1149
|
-
* Neuralwatt `GET /v1/quota` — subscription kWh usage (primary window) and
|
|
1150
|
-
* prepaid USD credit balance (secondary).
|
|
1151
|
-
*/
|
|
1152
|
-
async function fetchNeuralwattQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
1153
|
-
if (!isCanonicalNeuralwattBaseUrl(config.baseUrl)) return null;
|
|
1154
|
-
const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
|
|
1155
|
-
if (!apiKey) return null;
|
|
1156
|
-
const response = await fetch(`${NEURALWATT_BASE_URL}/quota`, {
|
|
1157
|
-
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
1158
|
-
redirect: "error",
|
|
1159
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1160
|
-
});
|
|
1161
|
-
if (!response.ok) {
|
|
1162
|
-
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
1163
|
-
? TERMINAL_QUOTA_FAILURE
|
|
1164
|
-
: null;
|
|
1165
|
-
}
|
|
1166
|
-
const body = asRecord(await readQuotaJson(response));
|
|
1167
|
-
const data = asRecord(body?.data) ?? body;
|
|
1168
|
-
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
1169
|
-
let windows = 0;
|
|
1170
|
-
const subscription = asRecord(data?.subscription);
|
|
1171
|
-
const kwhUsed = subscription ? toFiniteNumber(subscription.kwh_used) : undefined;
|
|
1172
|
-
const kwhIncluded = subscription ? toFiniteNumber(subscription.kwh_included) : undefined;
|
|
1173
|
-
if (kwhUsed !== undefined && kwhIncluded !== undefined && kwhIncluded > 0) {
|
|
1174
|
-
const percent = normalizePercent((kwhUsed / kwhIncluded) * 100);
|
|
1175
|
-
if (percent !== undefined) {
|
|
1176
|
-
quota.fiveHourPercent = percent;
|
|
1177
|
-
const periodEnd = subscription ? normalizeResetAt(subscription.current_period_end) : undefined;
|
|
1178
|
-
if (periodEnd !== undefined) quota.fiveHourResetAt = periodEnd;
|
|
1179
|
-
windows += 1;
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
const balance = asRecord(data?.balance);
|
|
1183
|
-
const totalCredits = balance ? toFiniteNumber(balance.total_credits_usd) : undefined;
|
|
1184
|
-
const remainingCredits = balance ? toFiniteNumber(balance.credits_remaining_usd) : undefined;
|
|
1185
|
-
if (totalCredits !== undefined && totalCredits > 0 && remainingCredits !== undefined) {
|
|
1186
|
-
// Utilization is CONSUMED credits, not the remaining share.
|
|
1187
|
-
const used = Math.max(0, totalCredits - remainingCredits);
|
|
1188
|
-
const percent = normalizePercent((used / totalCredits) * 100);
|
|
1189
|
-
if (percent !== undefined) {
|
|
1190
|
-
quota.customWindows = [...(quota.customWindows ?? []), { label: "Prepaid credits", percent }];
|
|
1191
|
-
windows += 1;
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
return windows > 0 ? report(provider, "neuralwatt:quota", quota) : null;
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
function report(
|
|
1198
|
-
provider: string,
|
|
1199
|
-
source: string,
|
|
1200
|
-
quota: ProviderQuota,
|
|
1201
|
-
aggregation?: CodexCapacityAggregation,
|
|
1202
|
-
): ProviderQuotaReport | null {
|
|
1203
|
-
if (!hasQuotaRows(quota)) return null;
|
|
1204
|
-
return {
|
|
1205
|
-
provider,
|
|
1206
|
-
label: providerLabel(provider),
|
|
1207
|
-
source,
|
|
1208
|
-
quota,
|
|
1209
|
-
updatedAt: quota.updatedAt,
|
|
1210
|
-
...(aggregation ? { aggregation } : {}),
|
|
1211
|
-
};
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
/**
|
|
1215
|
-
* Publish a credential-bound report, and routing evidence only when the producer
|
|
1216
|
-
* hands over its inference-only projection.
|
|
1217
|
-
*
|
|
1218
|
-
* The projection is deliberately not defaulted to the display quota. A producer must
|
|
1219
|
-
* decide that its rows really do constrain inference on the probed credential; omitting
|
|
1220
|
-
* the argument leaves the report display-only, so a new producer cannot inherit
|
|
1221
|
-
* provider-veto authority merely by calling this helper. Ownership alone is not the
|
|
1222
|
-
* scope decision: providerQuotaRoutingBinding resolving is necessary, never sufficient.
|
|
1223
|
-
*/
|
|
1224
|
-
function keyReport(
|
|
1225
|
-
provider: string,
|
|
1226
|
-
source: string,
|
|
1227
|
-
quota: ProviderQuota,
|
|
1228
|
-
config: OcxProviderConfig,
|
|
1229
|
-
probedCredential: string,
|
|
1230
|
-
inferenceQuota?: ProviderQuota,
|
|
1231
|
-
): ProviderQuotaReport | null {
|
|
1232
|
-
const result = report(provider, source, quota);
|
|
1233
|
-
if (!result || !inferenceQuota) return result;
|
|
1234
|
-
const binding = providerQuotaRoutingBinding(provider, config, probedCredential);
|
|
1235
|
-
if (binding) routingEvidence.set(result, { quota: inferenceQuota, binding });
|
|
1236
|
-
return result;
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
function tagNativeMainReport(
|
|
1240
|
-
value: ProviderQuotaReport | null,
|
|
1241
|
-
generation: number,
|
|
1242
|
-
): ProviderQuotaReport | null {
|
|
1243
|
-
if (value) nativeMainReportGenerations.set(value, generation);
|
|
1244
|
-
return value;
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
/**
|
|
1248
|
-
* Test-only seam: publish exactly as a credential-bound producer does, and hand back the
|
|
1249
|
-
* routing evidence the publication actually attached.
|
|
1250
|
-
*
|
|
1251
|
-
* Live producers all pass a projection today, so no probe fixture can prove the OTHER half
|
|
1252
|
-
* of the contract: that omitting it stays display-only. Routing an omitted argument through
|
|
1253
|
-
* the real helper keeps that provable, and a re-introduced `= quota` default would be
|
|
1254
|
-
* observed here (a defaulted parameter also fires for an explicitly undefined argument).
|
|
1255
|
-
*/
|
|
1256
|
-
export function publishKeyReportForTests(
|
|
1257
|
-
provider: string,
|
|
1258
|
-
source: string,
|
|
1259
|
-
quota: ProviderQuota,
|
|
1260
|
-
config: OcxProviderConfig,
|
|
1261
|
-
probedCredential: string,
|
|
1262
|
-
inferenceQuota?: ProviderQuota,
|
|
1263
|
-
): { report: ProviderQuotaReport | null; routing: ProviderQuotaRoutingEvidence | undefined } {
|
|
1264
|
-
const result = keyReport(provider, source, quota, config, probedCredential, inferenceQuota);
|
|
1265
|
-
return { report: result, routing: result ? routingEvidence.get(result) : undefined };
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolean {
|
|
1269
|
-
const generation = nativeMainReportGenerations.get(value);
|
|
1270
|
-
return (generation === undefined || isMainAccountIdentityGenerationLive(generation))
|
|
1271
|
-
&& (accountReportCurrent.get(value)?.() ?? true);
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
async function fetchChatGptForwardQuota(
|
|
1275
|
-
config: OcxConfig,
|
|
1276
|
-
provider: string,
|
|
1277
|
-
providerConfig: OcxProviderConfig,
|
|
1278
|
-
forceRefresh: boolean,
|
|
1279
|
-
prefetchedSnapshot?: CodexAuthAccountsSnapshotPromise,
|
|
1280
|
-
): Promise<ProviderQuotaReport | null> {
|
|
1281
|
-
if (providerCodexAccountMode(provider, providerConfig) === "direct") {
|
|
1282
|
-
const snapshot = await fetchMainAccountInfoSnapshot(forceRefresh);
|
|
1283
|
-
const quota = providerQuotaFromCodexQuota(snapshot.info.quota);
|
|
1284
|
-
if (quota) quota.updatedAt = Date.now();
|
|
1285
|
-
return quota
|
|
1286
|
-
? tagNativeMainReport(report(provider, "chatgpt:wham", quota), snapshot.mainIdentityGeneration)
|
|
1287
|
-
: null;
|
|
1288
|
-
}
|
|
1289
|
-
const snapshot = await (prefetchedSnapshot ?? listCodexAuthAccountsSnapshot(config, forceRefresh));
|
|
1290
|
-
const accounts = snapshot.accounts;
|
|
1291
|
-
const activeId = effectiveCodexAuthAccountId(config);
|
|
1292
|
-
const capacityAccounts = accounts.map(account => ({
|
|
1293
|
-
...account,
|
|
1294
|
-
active: account.id === activeId,
|
|
1295
|
-
quota: providerQuotaFromCodexQuota(account.quota),
|
|
1296
|
-
}));
|
|
1297
|
-
const active = capacityAccounts.find(account => account.active)
|
|
1298
|
-
?? capacityAccounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)
|
|
1299
|
-
?? capacityAccounts[0];
|
|
1300
|
-
const now = Date.now();
|
|
1301
|
-
const capacity = aggregateCodexPoolCapacity(capacityAccounts, now);
|
|
1302
|
-
if (capacity.aggregation && capacity.quota) {
|
|
1303
|
-
return tagNativeMainReport(
|
|
1304
|
-
report(
|
|
1305
|
-
provider,
|
|
1306
|
-
"chatgpt:wham",
|
|
1307
|
-
capacity.quota as ProviderQuota,
|
|
1308
|
-
publicCapacityAggregation(capacity.aggregation, "aggregate"),
|
|
1309
|
-
),
|
|
1310
|
-
snapshot.mainIdentityGeneration,
|
|
1311
|
-
);
|
|
1312
|
-
}
|
|
1313
|
-
const activeUsable = !!active && !active.paused && active.needsReauth !== true;
|
|
1314
|
-
const quota = activeUsable && active?.quota
|
|
1315
|
-
? { ...active.quota, updatedAt: active.quota.updatedAt ?? Date.now() } as CodexCapacityQuota
|
|
1316
|
-
: null;
|
|
1317
|
-
const quotaFresh = !!quota
|
|
1318
|
-
&& Number.isFinite(quota.updatedAt)
|
|
1319
|
-
&& now - quota.updatedAt < CODEX_CAPACITY_MAX_QUOTA_AGE_MS;
|
|
1320
|
-
if (quota && quotaFresh) {
|
|
1321
|
-
const fallback = report(
|
|
1322
|
-
provider,
|
|
1323
|
-
"chatgpt:wham",
|
|
1324
|
-
quota as ProviderQuota,
|
|
1325
|
-
capacity.aggregation
|
|
1326
|
-
? publicCapacityAggregation(capacity.aggregation, "effective-account-fallback")
|
|
1327
|
-
: undefined,
|
|
1328
|
-
);
|
|
1329
|
-
return tagNativeMainReport(fallback, snapshot.mainIdentityGeneration);
|
|
1330
|
-
}
|
|
1331
|
-
if (capacity.aggregation) {
|
|
1332
|
-
const updatedAt = Date.now();
|
|
1333
|
-
return tagNativeMainReport(
|
|
1334
|
-
{
|
|
1335
|
-
provider,
|
|
1336
|
-
label: providerLabel(provider),
|
|
1337
|
-
source: "chatgpt:wham",
|
|
1338
|
-
quota: { updatedAt },
|
|
1339
|
-
updatedAt,
|
|
1340
|
-
aggregation: publicCapacityAggregation(capacity.aggregation, "coverage-only"),
|
|
1341
|
-
},
|
|
1342
|
-
snapshot.mainIdentityGeneration,
|
|
1343
|
-
);
|
|
1344
|
-
}
|
|
1345
|
-
return null;
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
function centsValue(value: unknown): number | undefined {
|
|
1349
|
-
const rec = asRecord(value);
|
|
1350
|
-
return rec ? toFiniteNumber(rec.val) : undefined;
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
/** Decode JWT payload `sub` for xAI weekly credits when the stored credential lacks accountId. */
|
|
1354
|
-
function xaiUserIdFromAccessToken(accessToken: string): string | undefined {
|
|
1355
|
-
const parts = accessToken.split(".");
|
|
1356
|
-
if (parts.length < 2 || !parts[1]) return undefined;
|
|
1357
|
-
try {
|
|
1358
|
-
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as { sub?: unknown };
|
|
1359
|
-
return typeof payload.sub === "string" && payload.sub.trim() ? payload.sub.trim() : undefined;
|
|
1360
|
-
} catch {
|
|
1361
|
-
return undefined;
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
/**
|
|
1366
|
-
* Grok Build weekly credits envelope:
|
|
1367
|
-
* `{ config: { creditUsagePercent?, currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", end } } }`.
|
|
1368
|
-
* Omitted percent is treated as 0 (proto3 default).
|
|
1369
|
-
*/
|
|
1370
|
-
export function parseXaiCreditsResponse(value: unknown): { percent: number; resetAt?: number } | null {
|
|
1371
|
-
const body = asRecord(value);
|
|
1372
|
-
const config = asRecord(body?.config);
|
|
1373
|
-
if (!config) return null;
|
|
1374
|
-
const period = asRecord(config.currentPeriod);
|
|
1375
|
-
if (!period || period.type !== "USAGE_PERIOD_TYPE_WEEKLY") return null;
|
|
1376
|
-
let percent = 0;
|
|
1377
|
-
if (config.creditUsagePercent !== undefined) {
|
|
1378
|
-
const normalized = normalizePercent(config.creditUsagePercent);
|
|
1379
|
-
if (normalized === undefined) return null;
|
|
1380
|
-
percent = normalized;
|
|
1381
|
-
}
|
|
1382
|
-
const resetAt = normalizeResetAt(period.end);
|
|
1383
|
-
return {
|
|
1384
|
-
percent,
|
|
1385
|
-
...(resetAt !== undefined ? { resetAt } : {}),
|
|
1386
|
-
};
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
async function fetchXaiWeeklyCredits(accessToken: string, userId: string): Promise<ProviderQuota | null> {
|
|
1390
|
-
try {
|
|
1391
|
-
const response = await fetch(XAI_CREDITS_URL, {
|
|
1392
|
-
redirect: "error",
|
|
1393
|
-
headers: {
|
|
1394
|
-
Accept: "application/json",
|
|
1395
|
-
Authorization: `Bearer ${accessToken}`,
|
|
1396
|
-
[XAI_GROK_COMPATIBILITY.headers.tokenAuth]: "xai-grok-cli",
|
|
1397
|
-
[XAI_GROK_COMPATIBILITY.headers.authenticateResponse]: "authenticate-response",
|
|
1398
|
-
"x-userid": userId,
|
|
1399
|
-
[XAI_GROK_COMPATIBILITY.headers.clientVersion]: XAI_GROK_CLIENT_VERSION,
|
|
1400
|
-
},
|
|
1401
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1402
|
-
});
|
|
1403
|
-
if (!response.ok) return null;
|
|
1404
|
-
const parsed = parseXaiCreditsResponse(await readQuotaJson(response));
|
|
1405
|
-
if (!parsed) return null;
|
|
1406
|
-
return {
|
|
1407
|
-
weeklyPercent: parsed.percent,
|
|
1408
|
-
...(parsed.resetAt !== undefined ? { weeklyResetAt: parsed.resetAt } : {}),
|
|
1409
|
-
updatedAt: Date.now(),
|
|
1410
|
-
};
|
|
1411
|
-
} catch {
|
|
1412
|
-
return null;
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
async function fetchXaiQuota(provider: string, context: { accessToken: string; upstreamAccountId?: string }): Promise<ProviderQuotaReport | null> {
|
|
1417
|
-
const { accessToken } = context;
|
|
1418
|
-
|
|
1419
|
-
// Prefer the SuperGrok weekly credits window that actually gates prompting (#1283).
|
|
1420
|
-
const userId = context.upstreamAccountId?.trim() || xaiUserIdFromAccessToken(accessToken);
|
|
1421
|
-
if (userId) {
|
|
1422
|
-
const weekly = await fetchXaiWeeklyCredits(accessToken, userId);
|
|
1423
|
-
if (weekly) return report(provider, "xai:grok-billing-credits", weekly);
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
// Legacy monthly dollar pool — retained when weekly is unavailable.
|
|
1427
|
-
try {
|
|
1428
|
-
const response = await fetch(XAI_BILLING_URL, {
|
|
1429
|
-
redirect: "error",
|
|
1430
|
-
headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
|
|
1431
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1432
|
-
});
|
|
1433
|
-
if (!response.ok) return null;
|
|
1434
|
-
const body = asRecord(await readQuotaJson(response));
|
|
1435
|
-
const config = asRecord(body?.config);
|
|
1436
|
-
if (!config) return null;
|
|
1437
|
-
const limitCents = centsValue(config.monthlyLimit);
|
|
1438
|
-
const usedCents = centsValue(config.used);
|
|
1439
|
-
if (limitCents === undefined || usedCents === undefined || limitCents <= 0) return null;
|
|
1440
|
-
const percent = normalizePercent((usedCents / limitCents) * 100);
|
|
1441
|
-
if (percent === undefined) return null;
|
|
1442
|
-
return report(provider, "xai:grok-billing", {
|
|
1443
|
-
monthlyPercent: percent,
|
|
1444
|
-
monthlyResetAt: normalizeResetAt(config.billingPeriodEnd),
|
|
1445
|
-
updatedAt: Date.now(),
|
|
1446
|
-
});
|
|
1447
|
-
} catch {
|
|
1448
|
-
return null;
|
|
1449
|
-
}
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number } | null {
|
|
1453
|
-
const rec = asRecord(value);
|
|
1454
|
-
if (!rec) return null;
|
|
1455
|
-
const percent = normalizePercent(rec.utilization);
|
|
1456
|
-
const resetAt = normalizeResetAt(rec.resets_at);
|
|
1457
|
-
if (percent === undefined && resetAt === undefined) return null;
|
|
1458
|
-
return { percent, resetAt };
|
|
1459
|
-
}
|
|
1460
|
-
|
|
1461
|
-
function parseClaudeLimit(value: unknown): { label: string; percent: number; resetAt?: number } | null {
|
|
1462
|
-
const rec = asRecord(value);
|
|
1463
|
-
if (!rec) return null;
|
|
1464
|
-
const percent = normalizePercent(rec.percent);
|
|
1465
|
-
if (percent === undefined) return null;
|
|
1466
|
-
const scope = asRecord(rec.scope);
|
|
1467
|
-
const model = asRecord(scope?.model);
|
|
1468
|
-
const rawLabel = String(model?.display_name ?? "").trim();
|
|
1469
|
-
if (!rawLabel) return null;
|
|
1470
|
-
const lowerLabel = rawLabel.toLowerCase();
|
|
1471
|
-
const label = lowerLabel.includes("fable") ? "Fable"
|
|
1472
|
-
: lowerLabel.includes("opus") ? "Opus"
|
|
1473
|
-
: lowerLabel.includes("sonnet") ? "Sonnet"
|
|
1474
|
-
: rawLabel;
|
|
1475
|
-
const resetAt = normalizeResetAt(rec.resets_at);
|
|
1476
|
-
return { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
/** Claude's OAuth usage endpoint, probed with ONE account's own bearer token. */
|
|
1480
|
-
const anthropicUsageInflight = new Map<string, Promise<ProviderQuota | null>>();
|
|
1481
|
-
|
|
1482
|
-
/**
|
|
1483
|
-
* Anthropic per-credential usage.
|
|
1484
|
-
*
|
|
1485
|
-
* This endpoint reports quota only. Its body carries `five_hour`, `seven_day`, the
|
|
1486
|
-
* model-scoped weekly buckets (`seven_day_fable`/`_opus`/`_sonnet`) and a `limits` array,
|
|
1487
|
-
* and **no subscription or tier field** — nor does the OAuth token response, which yields only
|
|
1488
|
-
* `account.uuid` and `account.email_address` (`src/oauth/anthropic.ts`). That is why
|
|
1489
|
-
* `OAuthAccountSummary.plan` is `null` for Anthropic rather than populated here (#3777); it is
|
|
1490
|
-
* a missing upstream field, not an unfinished mapping.
|
|
1491
|
-
*
|
|
1492
|
-
* A tier must not be inferred from what is here. Percentages are normalized per account, so a
|
|
1493
|
-
* Max x5 seat at 50% is byte-identical to a Max x20 seat at 50%, and the presence of a
|
|
1494
|
-
* model-scoped window tracks entitlement rather than seat size. Populate `plan` only when
|
|
1495
|
-
* upstream returns the tier itself.
|
|
1496
|
-
*/
|
|
1497
|
-
async function fetchAnthropicUsageQuota(accessToken: string): Promise<ProviderQuota | null> {
|
|
1498
|
-
const joinable = anthropicUsageInflight.get(accessToken);
|
|
1499
|
-
if (joinable) return joinable;
|
|
1500
|
-
|
|
1501
|
-
const probe = (async (): Promise<ProviderQuota | null> => {
|
|
1502
|
-
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
1503
|
-
headers: {
|
|
1504
|
-
Accept: "application/json, text/plain, */*",
|
|
1505
|
-
"Content-Type": "application/json",
|
|
1506
|
-
"User-Agent": "claude-cli/2.1.63 (external, cli)",
|
|
1507
|
-
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05",
|
|
1508
|
-
Authorization: `Bearer ${accessToken}`,
|
|
1509
|
-
},
|
|
1510
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1511
|
-
});
|
|
1512
|
-
if (!response.ok) return null;
|
|
1513
|
-
const body = asRecord(await readQuotaJson(response));
|
|
1514
|
-
if (!body) return null;
|
|
1515
|
-
const fiveHour = parseClaudeBucket(body.five_hour);
|
|
1516
|
-
const sevenDay = parseClaudeBucket(body.seven_day);
|
|
1517
|
-
const fable = parseClaudeBucket(body.seven_day_fable);
|
|
1518
|
-
const opus = parseClaudeBucket(body.seven_day_opus);
|
|
1519
|
-
const sonnet = parseClaudeBucket(body.seven_day_sonnet);
|
|
1520
|
-
const customWindows: ProviderQuotaWindow[] = [];
|
|
1521
|
-
if (fable?.percent !== undefined) customWindows.push({ label: "Fable", percent: fable.percent, ...(fable.resetAt !== undefined ? { resetAt: fable.resetAt } : {}) });
|
|
1522
|
-
if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) });
|
|
1523
|
-
if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) });
|
|
1524
|
-
const knownLabels = new Set(customWindows.map(window => window.label.toLowerCase()));
|
|
1525
|
-
const limits = Array.isArray(body.limits) ? body.limits : [];
|
|
1526
|
-
for (const rawLimit of limits) {
|
|
1527
|
-
const limitRecord = asRecord(rawLimit);
|
|
1528
|
-
// `session` and `weekly_all` mirror the canonical five-hour and weekly
|
|
1529
|
-
// buckets above; only model-scoped weekly limits add a third window.
|
|
1530
|
-
if (String(limitRecord?.kind ?? "").trim().toLowerCase() !== "weekly_scoped") continue;
|
|
1531
|
-
const limit = parseClaudeLimit(rawLimit);
|
|
1532
|
-
if (!limit || knownLabels.has(limit.label.toLowerCase())) continue;
|
|
1533
|
-
knownLabels.add(limit.label.toLowerCase());
|
|
1534
|
-
customWindows.push(limit);
|
|
1535
|
-
}
|
|
1536
|
-
const quota: ProviderQuota = {
|
|
1537
|
-
// Claude's 5-hour window is a first-class rate limit, same as the Codex login 5h/weekly
|
|
1538
|
-
// rows: report it in the canonical fields so the dashboard renders it with the standard
|
|
1539
|
-
// "5-hour limit" label and ordering instead of as a generic extra window.
|
|
1540
|
-
...(fiveHour?.percent !== undefined ? { fiveHourPercent: fiveHour.percent } : {}),
|
|
1541
|
-
...(fiveHour?.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}),
|
|
1542
|
-
...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}),
|
|
1543
|
-
...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}),
|
|
1544
|
-
...(customWindows.length > 0 ? { customWindows } : {}),
|
|
1545
|
-
updatedAt: Date.now(),
|
|
1546
|
-
};
|
|
1547
|
-
// Empty / schema-changed payloads must not cache as "success with no bars".
|
|
1548
|
-
return hasQuotaRows(quota) ? quota : null;
|
|
1549
|
-
})().finally(() => {
|
|
1550
|
-
if (anthropicUsageInflight.get(accessToken) === probe) anthropicUsageInflight.delete(accessToken);
|
|
1551
|
-
});
|
|
1552
|
-
anthropicUsageInflight.set(accessToken, probe);
|
|
1553
|
-
return probe;
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
async function fetchAnthropicQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
1557
|
-
// Capture the account we intend to probe before awaiting — a mid-flight active
|
|
1558
|
-
// switch must not seed the wrong account's cache with this response.
|
|
1559
|
-
const probedAccountId = getAccountSet("anthropic")?.activeAccountId;
|
|
1560
|
-
const probedAccountKey = probedAccountId ? accountCacheKey("anthropic", probedAccountId) : null;
|
|
1561
|
-
const writerGeneration = captureConfigGeneration();
|
|
1562
|
-
let accessToken: string;
|
|
1563
|
-
try {
|
|
1564
|
-
accessToken = await getValidAccessToken("anthropic");
|
|
1565
|
-
} catch {
|
|
1566
|
-
return null;
|
|
1567
|
-
}
|
|
1568
|
-
const quota = await fetchAnthropicUsageQuota(accessToken);
|
|
1569
|
-
if (!quota) return null;
|
|
1570
|
-
// Share the active-account probe with the per-account cache so Providers-page
|
|
1571
|
-
// loads do not double-hit Anthropic's rate-limited usage endpoint.
|
|
1572
|
-
if (probedAccountId && probedAccountKey) {
|
|
1573
|
-
const stillOwnsToken = getAccountCredential("anthropic", probedAccountId)?.access === accessToken;
|
|
1574
|
-
if (stillOwnsToken && mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) {
|
|
1575
|
-
accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota });
|
|
1576
|
-
}
|
|
1577
|
-
}
|
|
1578
|
-
return report(provider, "anthropic:oauth-usage", quota);
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
/**
|
|
1582
|
-
* Provider-level Kiro row: the active account's usage, shown on the Providers page.
|
|
1583
|
-
*
|
|
1584
|
-
* The per-account cache is seeded from the same probe so opening that page does not read
|
|
1585
|
-
* the active account twice, and the account id is captured before the await so a
|
|
1586
|
-
* concurrent account switch cannot file this answer under the wrong account.
|
|
1587
|
-
*/
|
|
1588
|
-
async function fetchKiroQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
1589
|
-
const probedAccountId = getAccountSet("kiro")?.activeAccountId;
|
|
1590
|
-
if (!probedAccountId) return null;
|
|
1591
|
-
const probedAccountKey = accountCacheKey("kiro", probedAccountId);
|
|
1592
|
-
const writerGeneration = captureConfigGeneration();
|
|
1593
|
-
let snapshot: KiroUsageSnapshot | null;
|
|
1594
|
-
try {
|
|
1595
|
-
snapshot = await fetchKiroUsageSnapshot(await kiroUsageContextForAccount(probedAccountId));
|
|
1596
|
-
} catch {
|
|
1597
|
-
return null;
|
|
1598
|
-
}
|
|
1599
|
-
if (!snapshot) return null;
|
|
1600
|
-
if (mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) {
|
|
1601
|
-
accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota: snapshot.quota });
|
|
1602
|
-
commitKiroAccountUsageState(probedAccountKey, snapshot);
|
|
1603
|
-
}
|
|
1604
|
-
return report(provider, "kiro:usage-limits", snapshot.quota);
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
/**
|
|
1608
|
-
* Provider-level row probed from the key endpoint, for an account that CAN be probed.
|
|
1609
|
-
*
|
|
1610
|
-
* Written through the same account cache the passive path reads, so the measurement
|
|
1611
|
-
* survives a restart and the per-account rows at oauth-account-routes.ts:313 pick it up
|
|
1612
|
-
* with no mode change. Deliberately does not flip providerOAuthAccountQuotaMode: that
|
|
1613
|
-
* mode selects readPassiveProviderAccountQuotas, and the probed per-account path it would
|
|
1614
|
-
* switch to is gated on supportsPerAccountQuota, which has no meta-muse reader, so the
|
|
1615
|
-
* GUI account list would go from showing observations to showing nothing.
|
|
1616
|
-
*/
|
|
1617
|
-
async function fetchMuseKeyQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
1618
|
-
const probedAccountId = getAccountSet(provider)?.activeAccountId;
|
|
1619
|
-
if (!probedAccountId) return null;
|
|
1620
|
-
const oauthAccessToken = getAccountCredential(provider, probedAccountId)?.muse?.oauthAccessToken;
|
|
1621
|
-
// An imported or pasted credential has no account token and never will: it is
|
|
1622
|
-
// capability, not provider id, that decides whether a probe is possible.
|
|
1623
|
-
if (!oauthAccessToken) return null;
|
|
1624
|
-
const probedAccountKey = accountCacheKey(provider, probedAccountId);
|
|
1625
|
-
const writerGeneration = captureConfigGeneration();
|
|
1626
|
-
const quota = await fetchMuseKeyQuotaSnapshot(probedAccountId, oauthAccessToken);
|
|
1627
|
-
if (!quota) return null;
|
|
1628
|
-
if (mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) {
|
|
1629
|
-
// Hydrate before writing, for the same reason recordPassiveAccountQuota does:
|
|
1630
|
-
// persistAccountQuotaCache serializes the whole in-memory map.
|
|
1631
|
-
hydrateAccountQuotaCache();
|
|
1632
|
-
accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota });
|
|
1633
|
-
persistAccountQuotaCache();
|
|
1634
|
-
}
|
|
1635
|
-
return report(provider, `${provider}:key-endpoint`, quota);
|
|
1636
|
-
}
|
|
1637
|
-
/**
|
|
1638
|
-
* Provider-level row for a passive provider: the ACTIVE account's last observed
|
|
1639
|
-
* subscription windows, the same shape `fetchAnthropicQuota` and `fetchKiroQuota`
|
|
1640
|
-
* return.
|
|
1641
|
-
*
|
|
1642
|
-
* Cache-only. A dashboard load or `ocx account refresh` must never spend an inference
|
|
1643
|
-
* turn, so `forceRefresh` does not exist on this path — there is nothing to refresh.
|
|
1644
|
-
* `report.updatedAt` is the observation time, which is what both GUI surfaces render
|
|
1645
|
-
* as the relative age of the row.
|
|
1646
|
-
*/
|
|
1647
|
-
async function fetchPassiveProviderQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
1648
|
-
const activeId = getAccountSet(provider)?.activeAccountId;
|
|
1649
|
-
if (!activeId) return null;
|
|
1650
|
-
// Idempotent; without it a proxy restart shows nothing until the next streaming turn
|
|
1651
|
-
// even though the last observation is on disk.
|
|
1652
|
-
hydrateAccountQuotaCache();
|
|
1653
|
-
const entry = accountQuotaCache.get(accountCacheKey(provider, activeId));
|
|
1654
|
-
if (!entry?.quota) return null;
|
|
1655
|
-
const built = report(provider, `${provider}:subscription-observation`, entry.quota);
|
|
1656
|
-
// Tagged here rather than inside report(), which every probed path shares.
|
|
1657
|
-
return built ? { ...built, observed: true } : null;
|
|
1658
|
-
}
|
|
1659
|
-
|
|
1660
|
-
// ---------------------------------------------------------------------------
|
|
1661
|
-
// Per-account quota (multiauth)
|
|
1662
|
-
// ---------------------------------------------------------------------------
|
|
1663
|
-
|
|
1664
|
-
/**
|
|
1665
|
-
* Anthropic and Kiro both report usage per CREDENTIAL, so every logged-in account can be
|
|
1666
|
-
* probed with its own bearer token — the active-account selection and the local usage log
|
|
1667
|
-
* are irrelevant here. Mirrors the Codex pool behaviour
|
|
1668
|
-
* (codex/auth-api.ts:fetchPoolAccountQuota), including a per-account TTL so N accounts cost
|
|
1669
|
-
* at most N upstream calls per window. `ACCOUNT_QUOTA_TTL_MS` lives in `quota-wire.ts`
|
|
1670
|
-
* because the Kiro exhaustion reader applies the same staleness bound.
|
|
1671
|
-
*/
|
|
1672
|
-
type AccountQuotaCacheEntry = {
|
|
1673
|
-
ts: number;
|
|
1674
|
-
quota: ProviderQuota | null;
|
|
1675
|
-
/** Last probe failed (429 / network / expired login); still may hold last-good quota. */
|
|
1676
|
-
unavailable?: true;
|
|
1677
|
-
quotaFailure?: QuotaFailureCode;
|
|
1678
|
-
quotaFailureIsCurrent?: () => boolean;
|
|
1679
|
-
/** Private new-reader identity; never persisted or serialized. */
|
|
1680
|
-
identity?: string;
|
|
1681
|
-
isCurrent?: () => boolean;
|
|
1682
|
-
};
|
|
1683
|
-
/** Expired measurements become unknown; missing reset evidence never implies a fresh allowance. */
|
|
1684
|
-
function normalizeAnthropicQuota(quota: ProviderQuota | null | undefined, now: number): ProviderQuota | null {
|
|
1685
|
-
if (!quota) return null;
|
|
1686
|
-
const validReset = (resetAt: unknown): resetAt is number => typeof resetAt === "number"
|
|
1687
|
-
&& Number.isFinite(resetAt) && resetAt > 0 && Number.isFinite(new Date(resetAt).getTime());
|
|
1688
|
-
let result = quota;
|
|
1689
|
-
for (const [percent, reset] of [
|
|
1690
|
-
["fiveHourPercent", "fiveHourResetAt"],
|
|
1691
|
-
["weeklyPercent", "weeklyResetAt"],
|
|
1692
|
-
["monthlyPercent", "monthlyResetAt"],
|
|
1693
|
-
] as const) {
|
|
1694
|
-
const resetAt = quota[reset];
|
|
1695
|
-
if (resetAt === undefined) continue;
|
|
1696
|
-
const valid = validReset(resetAt);
|
|
1697
|
-
if (valid && resetAt > now) continue;
|
|
1698
|
-
if (result === quota) result = { ...quota };
|
|
1699
|
-
if (valid) delete result[percent];
|
|
1700
|
-
delete result[reset];
|
|
1701
|
-
}
|
|
1702
|
-
// Persisted rows validate only the outer quota object, so custom data may be malformed.
|
|
1703
|
-
if (quota.customWindows !== undefined) {
|
|
1704
|
-
const windows = Array.isArray(quota.customWindows) ? quota.customWindows : [];
|
|
1705
|
-
const retained: ProviderQuotaWindow[] = [];
|
|
1706
|
-
let changed = !Array.isArray(quota.customWindows);
|
|
1707
|
-
for (const window of windows) {
|
|
1708
|
-
if (!window || typeof window !== "object" || typeof window.label !== "string" || !window.label.trim()
|
|
1709
|
-
|| typeof window.percent !== "number" || !Number.isFinite(window.percent)
|
|
1710
|
-
|| window.percent < 0 || window.percent > 100) {
|
|
1711
|
-
changed = true;
|
|
1712
|
-
continue;
|
|
1713
|
-
}
|
|
1714
|
-
if (validReset(window.resetAt) && window.resetAt <= now) {
|
|
1715
|
-
changed = true;
|
|
1716
|
-
continue;
|
|
1717
|
-
}
|
|
1718
|
-
if (window.resetAt !== undefined && !validReset(window.resetAt)) {
|
|
1719
|
-
const normalized = { ...window };
|
|
1720
|
-
delete normalized.resetAt;
|
|
1721
|
-
retained.push(normalized);
|
|
1722
|
-
changed = true;
|
|
1723
|
-
} else {
|
|
1724
|
-
retained.push(window);
|
|
1725
|
-
}
|
|
1726
|
-
}
|
|
1727
|
-
if (changed) {
|
|
1728
|
-
if (result === quota) result = { ...quota };
|
|
1729
|
-
if (retained.length) result.customWindows = retained;
|
|
1730
|
-
else delete result.customWindows;
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
return hasQuotaRows(result) ? result : null;
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
const accountQuotaCache = new Map<string, AccountQuotaCacheEntry>();
|
|
1737
|
-
let explicitAccountEpoch = 0;
|
|
1738
|
-
|
|
1739
|
-
/**
|
|
1740
|
-
* Seed the cache from the last run, once.
|
|
1741
|
-
*
|
|
1742
|
-
* Without this a restart forgets every measurement, so the pool opens its next turn with
|
|
1743
|
-
* no idea which account has room — the exact blindness pre-dispatch selection exists to
|
|
1744
|
-
* remove. A hydrated row is still subject to the ordinary TTL, so it orders the first
|
|
1745
|
-
* request and is replaced by a live probe immediately after.
|
|
1746
|
-
*/
|
|
1747
|
-
let diskHydrated = false;
|
|
1748
|
-
function hydrateAccountQuotaCache(): void {
|
|
1749
|
-
if (diskHydrated) return;
|
|
1750
|
-
diskHydrated = true;
|
|
1751
|
-
for (const [key, quota] of readPersistedAccountQuotas()) {
|
|
1752
|
-
// Disk stores observation time, not the Anthropic usage probe's clock.
|
|
1753
|
-
if (!accountQuotaCache.has(key)) {
|
|
1754
|
-
const anthropic = key.startsWith("anthropic\u0000");
|
|
1755
|
-
accountQuotaCache.set(key, {
|
|
1756
|
-
ts: anthropic ? 0 : quota.updatedAt,
|
|
1757
|
-
quota: anthropic ? normalizeAnthropicQuota(quota, Date.now()) : quota,
|
|
1758
|
-
});
|
|
1759
|
-
}
|
|
1760
|
-
}
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
function persistAccountQuotaCache(): void {
|
|
1764
|
-
schedulePersistAccountQuotas(function* () {
|
|
1765
|
-
const now = Date.now();
|
|
1766
|
-
for (const [key, entry] of accountQuotaCache) {
|
|
1767
|
-
const quota = key.startsWith("anthropic\u0000") ? normalizeAnthropicQuota(entry.quota, now) : entry.quota;
|
|
1768
|
-
if (quota) yield [key, quota] as [string, ProviderQuota];
|
|
1769
|
-
}
|
|
1770
|
-
});
|
|
1771
|
-
}
|
|
1772
|
-
const accountQuotaInflight = new Map<string, Promise<AccountQuotaCacheEntry>>();
|
|
1773
|
-
let lastReconciledGeneration = 0;
|
|
1774
|
-
let liveAccountQuotaKeys = new Set<string>();
|
|
1775
|
-
let liveProviderQuotaKeys = new Set<string>();
|
|
1776
|
-
|
|
1777
|
-
function mayCommitAccountQuotaKey(key: string, writerGeneration: number): boolean {
|
|
1778
|
-
return writerGeneration >= lastReconciledGeneration || liveAccountQuotaKeys.has(key);
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
function mayCommitProviderQuotaKey(key: string, writerGeneration: number): boolean {
|
|
1782
|
-
return writerGeneration >= lastReconciledGeneration || liveProviderQuotaKeys.has(key);
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
export interface ProviderAccountQuota {
|
|
1786
|
-
accountId: string;
|
|
1787
|
-
quota: ProviderQuota | null;
|
|
1788
|
-
/** Set when the probe could not reach upstream (expired login, 429, network). */
|
|
1789
|
-
unavailable?: true;
|
|
1790
|
-
quotaFailure?: QuotaFailureCode;
|
|
1791
|
-
quotaFailureIsCurrent?: () => boolean;
|
|
1792
|
-
isCurrent?: () => boolean;
|
|
1793
|
-
}
|
|
1794
|
-
|
|
1795
|
-
/** Providers whose per-account quota can be probed. Extend as other OAuth APIs are covered. */
|
|
1796
|
-
export function supportsPerAccountQuota(provider: string): boolean {
|
|
1797
|
-
return provider === "anthropic" || provider === "kiro" || provider === "google-antigravity"
|
|
1798
|
-
|| explicitAccountReader(provider);
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
function explicitAccountReader(provider: string): boolean {
|
|
1802
|
-
return provider === "xai" || provider === "cursor" || provider === "kimi" || provider === "command-code";
|
|
1803
|
-
}
|
|
1804
|
-
|
|
1805
|
-
export function providerOAuthAccountQuotaMode(provider: string): AccountQuotaMode {
|
|
1806
|
-
return hasPassiveAccountQuota(provider) ? "passive" : supportsPerAccountQuota(provider) ? "probe" : "unsupported";
|
|
1807
|
-
}
|
|
1808
|
-
|
|
1809
|
-
function accountCacheKey(provider: string, accountId: string): string {
|
|
1810
|
-
return `${provider}\u0000${accountId}`;
|
|
1811
|
-
}
|
|
1812
|
-
|
|
1813
|
-
/**
|
|
1814
|
-
* Synchronous last-good per-account quota read for routing. Never probes the network.
|
|
1815
|
-
* Returns null when nothing is cached (or the cached row has no bars).
|
|
1816
|
-
*/
|
|
1817
|
-
export function getCachedProviderAccountQuota(provider: string, accountId: string): ProviderQuota | null {
|
|
1818
|
-
const entry = accountQuotaCache.get(accountCacheKey(provider, accountId));
|
|
1819
|
-
if (entry?.isCurrent && !entry.isCurrent()) return null;
|
|
1820
|
-
return provider === "anthropic" ? normalizeAnthropicQuota(entry?.quota, Date.now()) : entry?.quota ?? null;
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
/** Test-only: seed or clear the per-account quota cache without probing upstream. */
|
|
1824
|
-
export function setCachedProviderAccountQuotaForTests(
|
|
1825
|
-
provider: string,
|
|
1826
|
-
accountId: string,
|
|
1827
|
-
quota: ProviderQuota | null,
|
|
1828
|
-
): void {
|
|
1829
|
-
const key = accountCacheKey(provider, accountId);
|
|
1830
|
-
if (quota === null) {
|
|
1831
|
-
accountQuotaCache.delete(key);
|
|
1832
|
-
return;
|
|
1833
|
-
}
|
|
1834
|
-
accountQuotaCache.set(key, { ts: Date.now(), quota });
|
|
1835
|
-
}
|
|
1836
|
-
|
|
1837
|
-
/** Unified headers report utilization fractions and epoch-second reset times. */
|
|
1838
|
-
function anthropicHeaderResetAt(value: string | null): number | undefined {
|
|
1839
|
-
const seconds = toFiniteNumber(value);
|
|
1840
|
-
if (seconds === undefined || seconds <= 0) return undefined;
|
|
1841
|
-
const timestamp = seconds * 1000;
|
|
1842
|
-
return Number.isFinite(new Date(timestamp).getTime()) ? timestamp : undefined;
|
|
1843
|
-
}
|
|
1844
|
-
|
|
1845
|
-
export function parseAnthropicRateLimitHeaders(headers: Headers): ProviderQuota | null {
|
|
1846
|
-
const fiveHourPercent = normalizeUtilizationFraction(headers.get("anthropic-ratelimit-unified-5h-utilization"));
|
|
1847
|
-
const weeklyPercent = normalizeUtilizationFraction(headers.get("anthropic-ratelimit-unified-7d-utilization"));
|
|
1848
|
-
if (fiveHourPercent === undefined && weeklyPercent === undefined) return null;
|
|
1849
|
-
const fiveHourResetAt = anthropicHeaderResetAt(headers.get("anthropic-ratelimit-unified-5h-reset"));
|
|
1850
|
-
const weeklyResetAt = anthropicHeaderResetAt(headers.get("anthropic-ratelimit-unified-7d-reset"));
|
|
1851
|
-
return {
|
|
1852
|
-
...(fiveHourPercent !== undefined ? { fiveHourPercent } : {}),
|
|
1853
|
-
...(fiveHourPercent !== undefined && fiveHourResetAt !== undefined ? { fiveHourResetAt } : {}),
|
|
1854
|
-
...(weeklyPercent !== undefined ? { weeklyPercent } : {}),
|
|
1855
|
-
...(weeklyPercent !== undefined && weeklyResetAt !== undefined ? { weeklyResetAt } : {}),
|
|
1856
|
-
updatedAt: Date.now(),
|
|
1857
|
-
};
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
/** Reject unknown scales; round fraction conversion for persisted/displayed percentages. */
|
|
1861
|
-
function normalizeUtilizationFraction(value: string | null): number | undefined {
|
|
1862
|
-
const numeric = toFiniteNumber(value);
|
|
1863
|
-
if (numeric === undefined || numeric < 0 || numeric > 1) return undefined;
|
|
1864
|
-
return Math.round(numeric * 10_000) / 100;
|
|
1865
|
-
}
|
|
1866
|
-
|
|
1867
|
-
/**
|
|
1868
|
-
* Merge serving-account observations without advancing the usage probe's clock or
|
|
1869
|
-
* erasing model-specific windows. The caller owns credential attribution; this guard
|
|
1870
|
-
* prevents a retired account key from being revived by an older config generation.
|
|
1871
|
-
*/
|
|
1872
|
-
export function recordAnthropicAccountQuotaFromHeaders(
|
|
1873
|
-
accountId: string,
|
|
1874
|
-
headers: Headers,
|
|
1875
|
-
writerGeneration: number,
|
|
1876
|
-
): void {
|
|
1877
|
-
if (!accountId) return;
|
|
1878
|
-
const observed = parseAnthropicRateLimitHeaders(headers);
|
|
1879
|
-
if (!observed) return;
|
|
1880
|
-
const key = accountCacheKey("anthropic", accountId);
|
|
1881
|
-
if (!mayCommitAccountQuotaKey(key, writerGeneration)) return;
|
|
1882
|
-
// Hydrate before writing, for the same reason `recordPassiveAccountQuota` does: this write
|
|
1883
|
-
// arrives unprompted from the request path, and `persistAccountQuotaCache` serializes the
|
|
1884
|
-
// whole map. Landing before any reader has hydrated would persist this single row and erase
|
|
1885
|
-
// every other provider's saved row.
|
|
1886
|
-
hydrateAccountQuotaCache();
|
|
1887
|
-
const previous = accountQuotaCache.get(key);
|
|
1888
|
-
accountQuotaCache.set(key, {
|
|
1889
|
-
...previous,
|
|
1890
|
-
// Headers do not prove that the last usage probe succeeded.
|
|
1891
|
-
ts: previous?.ts ?? 0,
|
|
1892
|
-
quota: normalizeAnthropicQuota({
|
|
1893
|
-
...normalizeAnthropicQuota(previous?.quota, observed.updatedAt), ...observed,
|
|
1894
|
-
}, observed.updatedAt),
|
|
1895
|
-
});
|
|
1896
|
-
persistAccountQuotaCache();
|
|
1897
|
-
}
|
|
1898
|
-
|
|
1899
|
-
/**
|
|
1900
|
-
* Providers whose per-account quota is OBSERVED in-band, never probed.
|
|
1901
|
-
*
|
|
1902
|
-
* Deliberately separate from `supportsPerAccountQuota` rather than folded into it. That
|
|
1903
|
-
* predicate gates explicit upstream readers. Meta publishes no quota endpoint, so it
|
|
1904
|
-
* remains a cache-only observation even when every probe reader is account-scoped.
|
|
1905
|
-
*/
|
|
1906
|
-
export function hasPassiveAccountQuota(provider: string): boolean {
|
|
1907
|
-
return provider === "meta-muse";
|
|
1908
|
-
}
|
|
1909
|
-
|
|
1910
|
-
/**
|
|
1911
|
-
* Record a quota observed in-band on a streaming turn.
|
|
1912
|
-
*
|
|
1913
|
-
* The CALLER captures `writerGeneration` when it resolves the serving credential, not
|
|
1914
|
-
* this function at write time. A streaming turn is a long await, and a generation
|
|
1915
|
-
* captured immediately before the write cannot see a config or account change that
|
|
1916
|
-
* happened EARLIER in the same turn — which is exactly the case the fence exists for.
|
|
1917
|
-
*/
|
|
1918
|
-
export function recordPassiveAccountQuota(
|
|
1919
|
-
provider: string,
|
|
1920
|
-
accountId: string,
|
|
1921
|
-
quota: ProviderQuota,
|
|
1922
|
-
writerGeneration: number,
|
|
1923
|
-
): void {
|
|
1924
|
-
if (!hasPassiveAccountQuota(provider) || !accountId) return;
|
|
1925
|
-
const key = accountCacheKey(provider, accountId);
|
|
1926
|
-
if (!mayCommitAccountQuotaKey(key, writerGeneration)) return;
|
|
1927
|
-
// Hydrate BEFORE writing, not only on the read path. `persistAccountQuotaCache`
|
|
1928
|
-
// serializes the whole in-memory map, so a passive write that lands before anything
|
|
1929
|
-
// has read the cache would persist this one row and erase every other provider's
|
|
1930
|
-
// saved row -- and `diskHydrated` would then stop any later reader from recovering
|
|
1931
|
-
// them. A probe writer cannot hit this because its own read hydrates first; an
|
|
1932
|
-
// observation arrives unprompted, so it must hydrate itself.
|
|
1933
|
-
hydrateAccountQuotaCache();
|
|
1934
|
-
accountQuotaCache.set(key, { ts: Date.now(), quota });
|
|
1935
|
-
// Persisted so a restart keeps the last observation: with no probe to re-establish it,
|
|
1936
|
-
// a forgotten row stays forgotten until the user happens to run another streaming turn.
|
|
1937
|
-
persistAccountQuotaCache();
|
|
1938
|
-
// sweepExpiredOnWrite is deliberately NOT called. Existing probe writers call it
|
|
1939
|
-
// because they run on a poll; this runs on the request path, where a state sweep does
|
|
1940
|
-
// not belong. Passive rows are still reclaimed by generation reconciliation
|
|
1941
|
-
// (reconcileProviderAccountQuotaRows) and by the disk reader's age bound.
|
|
1942
|
-
}
|
|
1943
|
-
|
|
1944
|
-
/**
|
|
1945
|
-
* Cache-only per-account rows for a passive provider. Never probes, never refreshes.
|
|
1946
|
-
*
|
|
1947
|
-
* An account with no observation is OMITTED rather than returned with `quota: null` and
|
|
1948
|
-
* `unavailable`: that pair means "a probe was attempted and failed", and no probe was
|
|
1949
|
-
* ever attempted here. A user who has not yet run a streaming turn simply has no
|
|
1950
|
-
* measurement, which is not an error state.
|
|
1951
|
-
*/
|
|
1952
|
-
export function readPassiveProviderAccountQuotas(provider: string): ProviderAccountQuota[] {
|
|
1953
|
-
if (!hasPassiveAccountQuota(provider)) return [];
|
|
1954
|
-
// Idempotent, and otherwise only reached from probe paths a passive provider never
|
|
1955
|
-
// enters — without it a restart shows nothing until the next streaming turn, even
|
|
1956
|
-
// though the row is sitting on disk.
|
|
1957
|
-
hydrateAccountQuotaCache();
|
|
1958
|
-
const set = getAccountSet(provider);
|
|
1959
|
-
if (!set) return [];
|
|
1960
|
-
const rows: ProviderAccountQuota[] = [];
|
|
1961
|
-
for (const account of set.accounts) {
|
|
1962
|
-
const entry = accountQuotaCache.get(accountCacheKey(provider, account.id));
|
|
1963
|
-
if (entry?.quota) rows.push({ accountId: account.id, quota: entry.quota });
|
|
1964
|
-
}
|
|
1965
|
-
return rows;
|
|
1966
|
-
}
|
|
1967
|
-
|
|
1968
|
-
export function sweepExpiredProviderAccountQuotaRows(now = Date.now()): number {
|
|
1969
|
-
let removed = 0;
|
|
1970
|
-
for (const [key, entry] of accountQuotaCache) {
|
|
1971
|
-
// Anthropic observations extend retention, never the usage probe's eligibility clock.
|
|
1972
|
-
const retainedAt = key.startsWith("anthropic\u0000")
|
|
1973
|
-
? Math.max(entry.ts, entry.quota?.updatedAt ?? 0)
|
|
1974
|
-
: entry.ts;
|
|
1975
|
-
if (retainedAt + ACCOUNT_QUOTA_TTL_MS > now) continue;
|
|
1976
|
-
accountQuotaCache.delete(key);
|
|
1977
|
-
removed += 1;
|
|
1978
|
-
}
|
|
1979
|
-
return removed;
|
|
1980
|
-
}
|
|
1981
|
-
|
|
1982
|
-
export function reconcileProviderAccountQuotaRows(context: GenerationContext): number {
|
|
1983
|
-
if (context.generation <= lastReconciledGeneration) return 0;
|
|
1984
|
-
let removed = 0;
|
|
1985
|
-
for (const key of accountQuotaCache.keys()) {
|
|
1986
|
-
if (context.oauthAccountKeys.has(key)) continue;
|
|
1987
|
-
accountQuotaCache.delete(key);
|
|
1988
|
-
removed += 1;
|
|
1989
|
-
}
|
|
1990
|
-
// Kiro exhaustion rows are keyed identically, so they retire with their quota row; a
|
|
1991
|
-
// verdict outliving its account would hand the replacement a cooldown it never earned.
|
|
1992
|
-
removed += reconcileKiroAccountUsageState(context.oauthAccountKeys);
|
|
1993
|
-
if (cache) {
|
|
1994
|
-
const reports = cache.response.reports.filter(report => context.providerNames.has(report.provider));
|
|
1995
|
-
removed += cache.response.reports.length - reports.length;
|
|
1996
|
-
cache = { ...cache, response: { ...cache.response, reports } };
|
|
1997
|
-
replaceCachedProviderQuotas(reports, routingEvidence);
|
|
1998
|
-
}
|
|
1999
|
-
liveAccountQuotaKeys = new Set(context.oauthAccountKeys);
|
|
2000
|
-
liveProviderQuotaKeys = new Set(context.providerNames);
|
|
2001
|
-
lastReconciledGeneration = context.generation;
|
|
2002
|
-
return removed;
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
|
-
/** Test-only reset so a direct reconcile call in one file cannot leak across files. */
|
|
2006
|
-
export function resetProviderQuotaReconcileStateForTests(): void {
|
|
2007
|
-
lastReconciledGeneration = 0;
|
|
2008
|
-
liveAccountQuotaKeys = new Set();
|
|
2009
|
-
liveProviderQuotaKeys = new Set();
|
|
2010
|
-
}
|
|
2011
|
-
|
|
2012
|
-
/** Drop cached per-account rows (all, or just one provider's). */
|
|
2013
|
-
export function clearAccountQuotaCache(provider?: string): void {
|
|
2014
|
-
explicitAccountEpoch += 1;
|
|
2015
|
-
if (!provider) {
|
|
2016
|
-
accountQuotaCache.clear();
|
|
2017
|
-
accountQuotaInflight.clear();
|
|
2018
|
-
clearKiroAccountUsageState();
|
|
2019
|
-
// A cleared cache must not be re-seeded from the file it was just cleared of, and any
|
|
2020
|
-
// pending write of the old rows is abandoned.
|
|
2021
|
-
diskHydrated = false;
|
|
2022
|
-
cancelPendingAccountQuotaPersist();
|
|
2023
|
-
return;
|
|
2024
|
-
}
|
|
2025
|
-
const prefix = `${provider}\u0000`;
|
|
2026
|
-
for (const key of [...accountQuotaCache.keys()]) {
|
|
2027
|
-
if (key.startsWith(prefix)) accountQuotaCache.delete(key);
|
|
2028
|
-
}
|
|
2029
|
-
clearKiroAccountUsageState(prefix);
|
|
2030
|
-
// Drop in-flight probes too so a late resolve cannot repopulate after logout/remove.
|
|
2031
|
-
for (const key of [...accountQuotaInflight.keys()]) {
|
|
2032
|
-
if (key.startsWith(prefix)) accountQuotaInflight.delete(key);
|
|
2033
|
-
}
|
|
2034
|
-
persistAccountQuotaCache();
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2037
|
-
/**
|
|
2038
|
-
* Resolve a bearer for quota probing without silently adopting a newer global
|
|
2039
|
-
* Claude CLI credential into a background multiauth slot.
|
|
2040
|
-
*
|
|
2041
|
-
* - Fresh stored access → use as-is (no refresh).
|
|
2042
|
-
* - Active account with expired access → normal refresh path.
|
|
2043
|
-
* - Background `local-cli` with expired access → fail closed (unavailable):
|
|
2044
|
-
* `getValidAccessTokenForAccount` can persist a mismatched Claude CLI identity.
|
|
2045
|
-
* - Background ordinary OAuth (`source !== "local-cli"`) → safe to refresh;
|
|
2046
|
-
* Anthropic's lock only adopts disk credentials for `local-cli` rows.
|
|
2047
|
-
*/
|
|
2048
|
-
async function getTokenForAccountQuotaProbe(provider: string, accountId: string): Promise<string> {
|
|
2049
|
-
const stored = getAccountCredential(provider, accountId);
|
|
2050
|
-
if (!stored) throw new Error("account credential missing");
|
|
2051
|
-
if (stored.expires > Date.now() + ACCOUNT_TOKEN_SKEW_MS) return stored.access;
|
|
2052
|
-
const activeId = getAccountSet(provider)?.activeAccountId;
|
|
2053
|
-
if (activeId !== accountId && stored.source === "local-cli") {
|
|
2054
|
-
throw new Error("background local-cli token expired; skip CLI-adopting refresh for quota probe");
|
|
2055
|
-
}
|
|
2056
|
-
return getValidAccessTokenForAccount(provider, accountId);
|
|
2057
|
-
}
|
|
2058
|
-
|
|
2059
|
-
function explicitQuotaConfig(provider: string, configured?: OcxProviderConfig): OcxProviderConfig | undefined {
|
|
2060
|
-
if (configured) return configured;
|
|
2061
|
-
const entry = getProviderRegistryEntry(provider);
|
|
2062
|
-
return entry ? { adapter: entry.adapter, baseUrl: entry.baseUrl, authMode: "oauth" } : undefined;
|
|
2063
|
-
}
|
|
2064
|
-
|
|
2065
|
-
function explicitQuotaIdentity(provider: string, accountId: string, configured?: OcxProviderConfig): string | undefined {
|
|
2066
|
-
const credential = getAccountCredential(provider, accountId);
|
|
2067
|
-
const target = explicitQuotaConfig(provider, configured);
|
|
2068
|
-
if (!credential || !target) return undefined;
|
|
2069
|
-
return quotaCredentialIdentity(provider, accountId, credential, target);
|
|
2070
|
-
}
|
|
2071
|
-
|
|
2072
|
-
function quotaCredentialIdentity(provider: string, accountId: string, credential: NonNullable<ReturnType<typeof getAccountCredential>>, target: OcxProviderConfig): string {
|
|
2073
|
-
return createHash("sha256").update(JSON.stringify([
|
|
2074
|
-
provider, accountId, credential.access, credential.refresh, credential.expires,
|
|
2075
|
-
credential.accountId, credential.projectId, credential.source,
|
|
2076
|
-
target.adapter, target.baseUrl, target.authMode, target.disabled === true,
|
|
2077
|
-
])).digest("hex");
|
|
2078
|
-
}
|
|
2079
|
-
|
|
2080
|
-
function explicitQuotaDestination(provider: string, config: OcxProviderConfig): boolean {
|
|
2081
|
-
if (config.disabled === true || config.authMode !== "oauth") return false;
|
|
2082
|
-
if (provider === "kimi") return isCanonicalKimiCodeBaseUrl(config.baseUrl);
|
|
2083
|
-
if (provider === "command-code") return isCanonicalCommandCodeBaseUrl(config.baseUrl);
|
|
2084
|
-
// These readers use fixed canonical billing origins, never config.baseUrl.
|
|
2085
|
-
return provider === "xai" || provider === "cursor";
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
|
-
async function readExplicitAccountQuota(provider: string, accountId: string, configured?: OcxProviderConfig): Promise<{
|
|
2089
|
-
result: ProviderQuotaProbeResult;
|
|
2090
|
-
identity: string | undefined;
|
|
2091
|
-
isCurrent: () => boolean;
|
|
2092
|
-
} | null> {
|
|
2093
|
-
const target = explicitQuotaConfig(provider, configured);
|
|
2094
|
-
if (!target || !explicitQuotaDestination(provider, target)) return null;
|
|
2095
|
-
const config = { ...target };
|
|
2096
|
-
const epoch = explicitAccountEpoch;
|
|
2097
|
-
const accessToken = await getTokenForAccountQuotaProbe(provider, accountId);
|
|
2098
|
-
const credential = getAccountCredential(provider, accountId);
|
|
2099
|
-
if (!credential || credential.access !== accessToken) return null;
|
|
2100
|
-
// Pair the post-renewal credential with the destination captured before renewal.
|
|
2101
|
-
const identity = explicitQuotaIdentity(provider, accountId, config);
|
|
2102
|
-
const isCurrent = () => epoch === explicitAccountEpoch
|
|
2103
|
-
&& identity === explicitQuotaIdentity(provider, accountId, configured);
|
|
2104
|
-
if (!isCurrent()) return null;
|
|
2105
|
-
let result: ProviderQuotaProbeResult;
|
|
2106
|
-
switch (provider) {
|
|
2107
|
-
case "xai": result = await fetchXaiQuota(provider, { accessToken, upstreamAccountId: credential.accountId }); break;
|
|
2108
|
-
case "cursor": result = await fetchCursorQuota(provider, accessToken); break;
|
|
2109
|
-
case "kimi": result = await fetchKimiQuota(provider, config, accessToken); break;
|
|
2110
|
-
case "command-code": result = await fetchCommandCodeQuota(provider, config, accessToken); break;
|
|
2111
|
-
default: return null;
|
|
2112
|
-
}
|
|
2113
|
-
return { result, identity, isCurrent };
|
|
2114
|
-
}
|
|
2115
|
-
|
|
2116
|
-
async function fetchExplicitAccountQuota(provider: string, accountId: string, force: boolean, configured?: OcxProviderConfig): Promise<AccountQuotaCacheEntry> {
|
|
2117
|
-
const key = accountCacheKey(provider, accountId);
|
|
2118
|
-
const identity = explicitQuotaIdentity(provider, accountId, configured);
|
|
2119
|
-
const previous = accountQuotaCache.get(key);
|
|
2120
|
-
const cached = identity && previous?.identity === identity && previous.isCurrent?.() ? previous : undefined;
|
|
2121
|
-
if (!force && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS
|
|
2122
|
-
&& (!cached.quota || Date.now() - cached.quota.updatedAt < LAST_GOOD_MAX_AGE_MS)) return cached;
|
|
2123
|
-
const flightKey = `${key}\u0000${identity ?? "missing"}`;
|
|
2124
|
-
const running = accountQuotaInflight.get(flightKey);
|
|
2125
|
-
if (running) return running;
|
|
2126
|
-
const epoch = explicitAccountEpoch;
|
|
2127
|
-
const lastGood = cached?.quota && Date.now() - cached.quota.updatedAt < LAST_GOOD_MAX_AGE_MS ? cached.quota : null;
|
|
2128
|
-
const flight = (async (): Promise<AccountQuotaCacheEntry> => {
|
|
2129
|
-
let read: Awaited<ReturnType<typeof readExplicitAccountQuota>> = null;
|
|
2130
|
-
try { read = await readExplicitAccountQuota(provider, accountId, configured); } catch { /* unavailable */ }
|
|
2131
|
-
const isCurrent = read?.isCurrent ?? (() => epoch === explicitAccountEpoch && !!identity
|
|
2132
|
-
&& identity === explicitQuotaIdentity(provider, accountId, configured));
|
|
2133
|
-
const result = read?.result;
|
|
2134
|
-
const current = epoch === explicitAccountEpoch && isCurrent();
|
|
2135
|
-
const quota = current && result && typeof result !== "symbol" ? result.quota : null;
|
|
2136
|
-
const empty = result === AUTHORITATIVE_EMPTY_QUOTA;
|
|
2137
|
-
const entry: AccountQuotaCacheEntry = {
|
|
2138
|
-
ts: Date.now(),
|
|
2139
|
-
quota: quota ?? (current && result !== TERMINAL_QUOTA_FAILURE && !empty
|
|
2140
|
-
&& lastGood && Date.now() - lastGood.updatedAt < LAST_GOOD_MAX_AGE_MS ? lastGood : null),
|
|
2141
|
-
...(!current || (!quota && !empty) ? { unavailable: true as const } : {}),
|
|
2142
|
-
identity: read?.identity ?? identity,
|
|
2143
|
-
isCurrent: () => epoch === explicitAccountEpoch && isCurrent(),
|
|
2144
|
-
};
|
|
2145
|
-
if (entry.isCurrent?.()) accountQuotaCache.set(key, entry);
|
|
2146
|
-
return entry;
|
|
2147
|
-
})().finally(() => { if (accountQuotaInflight.get(flightKey) === flight) accountQuotaInflight.delete(flightKey); });
|
|
2148
|
-
accountQuotaInflight.set(flightKey, flight);
|
|
2149
|
-
return flight;
|
|
2150
|
-
}
|
|
2151
|
-
|
|
2152
|
-
async function fetchExplicitCurrentQuota(provider: string, config: OcxProviderConfig, liveConfig: OcxConfig): Promise<ProviderQuotaProbeResult> {
|
|
2153
|
-
const id = getAccountSet(provider)?.activeAccountId;
|
|
2154
|
-
if (!id) return null;
|
|
2155
|
-
const read = await readExplicitAccountQuota(provider, id, config);
|
|
2156
|
-
if (!read) return null;
|
|
2157
|
-
const isCurrent = () => liveConfig.providers[provider] === config
|
|
2158
|
-
&& read.isCurrent() && getAccountSet(provider)?.activeAccountId === id;
|
|
2159
|
-
if (!isCurrent()) return TERMINAL_QUOTA_FAILURE;
|
|
2160
|
-
if (read.result && typeof read.result !== "symbol") accountReportCurrent.set(read.result, isCurrent);
|
|
2161
|
-
return read.result;
|
|
2162
|
-
}
|
|
2163
|
-
|
|
2164
|
-
function antigravityQuotaDiagnosticIdentity(accountId: string, credential = getAccountCredential("google-antigravity", accountId)): string | undefined {
|
|
2165
|
-
return credential ? quotaCredentialIdentity("google-antigravity", accountId, credential, {
|
|
2166
|
-
adapter: "google", baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE, authMode: "oauth",
|
|
2167
|
-
}) : undefined;
|
|
2168
|
-
}
|
|
2169
|
-
|
|
2170
|
-
async function fetchAccountQuota(
|
|
2171
|
-
provider: string,
|
|
2172
|
-
accountId: string,
|
|
2173
|
-
forceRefresh: boolean,
|
|
2174
|
-
providerConfig?: OcxProviderConfig,
|
|
2175
|
-
): Promise<AccountQuotaCacheEntry> {
|
|
2176
|
-
if (!supportsPerAccountQuota(provider)) return { ts: Date.now(), quota: null, unavailable: true };
|
|
2177
|
-
if (explicitAccountReader(provider)) return fetchExplicitAccountQuota(provider, accountId, forceRefresh, providerConfig);
|
|
2178
|
-
if (provider === "anthropic") hydrateAccountQuotaCache();
|
|
2179
|
-
const key = accountCacheKey(provider, accountId);
|
|
2180
|
-
const writerGeneration = captureConfigGeneration();
|
|
2181
|
-
const cached = accountQuotaCache.get(key);
|
|
2182
|
-
if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) {
|
|
2183
|
-
if (provider === "google-antigravity" && cached.quotaFailure && cached.quotaFailureIsCurrent?.() !== true) return { ...cached, quotaFailure: undefined };
|
|
2184
|
-
return provider === "anthropic" ? { ...cached, quota: normalizeAnthropicQuota(cached.quota, Date.now()) } : cached;
|
|
2185
|
-
}
|
|
2186
|
-
const joinable = accountQuotaInflight.get(key);
|
|
2187
|
-
if (joinable) return joinable;
|
|
2188
|
-
|
|
2189
|
-
const epoch = explicitAccountEpoch;
|
|
2190
|
-
const probe = (async (): Promise<AccountQuotaCacheEntry> => {
|
|
2191
|
-
let diagnosticIdentity: string | undefined;
|
|
2192
|
-
let quotaFailure: QuotaFailureCode | undefined;
|
|
2193
|
-
const quotaFailureIsCurrent = () => {
|
|
2194
|
-
try { return epoch === explicitAccountEpoch && diagnosticIdentity !== undefined && diagnosticIdentity === antigravityQuotaDiagnosticIdentity(accountId); }
|
|
2195
|
-
catch { return false; }
|
|
2196
|
-
};
|
|
2197
|
-
const diagnosticFields = () => quotaFailure && quotaFailureIsCurrent() ? { quotaFailure, quotaFailureIsCurrent } : {};
|
|
2198
|
-
try {
|
|
2199
|
-
if (provider === "google-antigravity") diagnosticIdentity = antigravityQuotaDiagnosticIdentity(accountId);
|
|
2200
|
-
let quota: ProviderQuota | null;
|
|
2201
|
-
let kiroSnapshot: KiroUsageSnapshot | null = null;
|
|
2202
|
-
if (provider === "kiro") {
|
|
2203
|
-
// Kiro resolves the bearer and its routing metadata from ONE account-scoped
|
|
2204
|
-
// snapshot. It deliberately does not use getTokenForAccountQuotaProbe: that
|
|
2205
|
-
// helper refuses to refresh a background `local-cli` slot because Anthropic's
|
|
2206
|
-
// lock can adopt a mismatched Claude CLI identity, but Kiro marks every
|
|
2207
|
-
// CLI-imported credential `local-cli`, so the same rule would blank the quota of
|
|
2208
|
-
// every inactive pool account the moment its token expired.
|
|
2209
|
-
kiroSnapshot = await fetchKiroUsageSnapshot(await kiroUsageContextForAccount(accountId));
|
|
2210
|
-
quota = kiroSnapshot?.quota ?? null;
|
|
2211
|
-
} else {
|
|
2212
|
-
const token = await getTokenForAccountQuotaProbe(provider, accountId);
|
|
2213
|
-
if (provider === "google-antigravity") {
|
|
2214
|
-
// Per-account Gem/Cla windows (#1082). The project id is part of the stored
|
|
2215
|
-
// credential; without it the probe cannot be made, and that is "unavailable",
|
|
2216
|
-
// never 0%.
|
|
2217
|
-
const credential = getAccountCredential(provider, accountId);
|
|
2218
|
-
diagnosticIdentity = credential?.access === token ? antigravityQuotaDiagnosticIdentity(accountId, credential) : undefined;
|
|
2219
|
-
if (!diagnosticIdentity || !credential?.projectId) throw new Error("antigravity account unavailable");
|
|
2220
|
-
const result = await probeAntigravityUsageQuota(token, credential.projectId);
|
|
2221
|
-
quota = result.kind === "available" ? result.quota : null;
|
|
2222
|
-
if (result.kind === "unavailable") quotaFailure = result.failure;
|
|
2223
|
-
} else if (provider === "anthropic") {
|
|
2224
|
-
quota = await fetchAnthropicUsageQuota(token);
|
|
2225
|
-
} else {
|
|
2226
|
-
return { ts: Date.now(), quota: null, unavailable: true };
|
|
2227
|
-
}
|
|
2228
|
-
}
|
|
2229
|
-
if (!quota) {
|
|
2230
|
-
// Preserve last-good bars and mark unavailable; advance TTL so failures
|
|
2231
|
-
// negative-cache instead of re-probing on every GUI poll.
|
|
2232
|
-
const entry: AccountQuotaCacheEntry = {
|
|
2233
|
-
ts: Date.now(),
|
|
2234
|
-
// Settle once for all joiners against observations committed during the probe.
|
|
2235
|
-
quota: provider === "anthropic"
|
|
2236
|
-
? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null,
|
|
2237
|
-
unavailable: true,
|
|
2238
|
-
...diagnosticFields(),
|
|
2239
|
-
};
|
|
2240
|
-
if (mayCommitAccountQuotaKey(key, writerGeneration)) {
|
|
2241
|
-
accountQuotaCache.set(key, entry);
|
|
2242
|
-
if (provider === "kiro") commitKiroAccountUsageState(key, null);
|
|
2243
|
-
sweepExpiredOnWrite(entry.ts);
|
|
2244
|
-
}
|
|
2245
|
-
return entry;
|
|
2246
|
-
}
|
|
2247
|
-
const entry: AccountQuotaCacheEntry = {
|
|
2248
|
-
ts: Date.now(), quota: provider === "anthropic" ? normalizeAnthropicQuota(quota, Date.now()) : quota,
|
|
2249
|
-
};
|
|
2250
|
-
if (mayCommitAccountQuotaKey(key, writerGeneration)) {
|
|
2251
|
-
accountQuotaCache.set(key, entry);
|
|
2252
|
-
// Exhaustion state rides the SAME commit guard as the quota row: a probe from a
|
|
2253
|
-
// superseded config generation must not publish either half.
|
|
2254
|
-
if (provider === "kiro") commitKiroAccountUsageState(key, kiroSnapshot);
|
|
2255
|
-
sweepExpiredOnWrite(entry.ts);
|
|
2256
|
-
}
|
|
2257
|
-
return entry;
|
|
2258
|
-
} catch {
|
|
2259
|
-
if (provider === "google-antigravity") quotaFailure = "account_unavailable";
|
|
2260
|
-
const entry: AccountQuotaCacheEntry = {
|
|
2261
|
-
ts: Date.now(),
|
|
2262
|
-
quota: provider === "anthropic"
|
|
2263
|
-
? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null,
|
|
2264
|
-
unavailable: true,
|
|
2265
|
-
...diagnosticFields(),
|
|
2266
|
-
};
|
|
2267
|
-
if (mayCommitAccountQuotaKey(key, writerGeneration)) {
|
|
2268
|
-
accountQuotaCache.set(key, entry);
|
|
2269
|
-
sweepExpiredOnWrite(entry.ts);
|
|
2270
|
-
}
|
|
2271
|
-
return entry;
|
|
2272
|
-
}
|
|
2273
|
-
})().finally(() => {
|
|
2274
|
-
if (accountQuotaInflight.get(key) === probe) accountQuotaInflight.delete(key);
|
|
2275
|
-
});
|
|
2276
|
-
accountQuotaInflight.set(key, probe);
|
|
2277
|
-
return probe;
|
|
2278
|
-
}
|
|
2279
|
-
|
|
2280
|
-
/**
|
|
2281
|
-
* Per-account quota rows for a provider's logged-in accounts. Probes run in parallel; a
|
|
2282
|
-
* single failing account never blocks the others.
|
|
2283
|
-
*/
|
|
2284
|
-
export async function fetchProviderAccountQuotas(
|
|
2285
|
-
provider: string,
|
|
2286
|
-
forceRefresh = false,
|
|
2287
|
-
providerConfig?: OcxProviderConfig,
|
|
2288
|
-
): Promise<ProviderAccountQuota[]> {
|
|
2289
|
-
if (!supportsPerAccountQuota(provider)) return [];
|
|
2290
|
-
const set = getAccountSet(provider);
|
|
2291
|
-
if (!set) return [];
|
|
2292
|
-
return mapQuotaRoster(set.accounts, async account => {
|
|
2293
|
-
const entry = await fetchAccountQuota(provider, account.id, forceRefresh, providerConfig);
|
|
2294
|
-
const result: ProviderAccountQuota = {
|
|
2295
|
-
accountId: account.id,
|
|
2296
|
-
quota: provider === "anthropic" ? normalizeAnthropicQuota(entry.quota, Date.now()) : entry.quota,
|
|
2297
|
-
...(entry.unavailable ? { unavailable: true as const } : {}),
|
|
2298
|
-
...(entry.unavailable && entry.quotaFailure && entry.quotaFailureIsCurrent?.() === true ? { quotaFailure: entry.quotaFailure } : {}),
|
|
2299
|
-
};
|
|
2300
|
-
if (entry.quotaFailureIsCurrent) Object.defineProperty(result, "quotaFailureIsCurrent", { value: entry.quotaFailureIsCurrent });
|
|
2301
|
-
if (!explicitAccountReader(provider)) return result;
|
|
2302
|
-
const identity = entry.identity;
|
|
2303
|
-
Object.defineProperty(result, "isCurrent", { value: () => {
|
|
2304
|
-
if (entry.isCurrent) return entry.isCurrent();
|
|
2305
|
-
const credential = getAccountCredential(provider, account.id);
|
|
2306
|
-
return !!credential && (!identity || explicitQuotaIdentity(provider, account.id, providerConfig) === identity);
|
|
2307
|
-
} });
|
|
2308
|
-
return result;
|
|
2309
|
-
});
|
|
2310
|
-
}
|
|
2311
|
-
|
|
2312
|
-
function normalizedBaseUrl(value: string): string | null {
|
|
2313
|
-
try {
|
|
2314
|
-
const url = new URL(value);
|
|
2315
|
-
if (url.username || url.password || url.search || url.hash) return null;
|
|
2316
|
-
return `${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`;
|
|
2317
|
-
} catch {
|
|
2318
|
-
return null;
|
|
2319
|
-
}
|
|
2320
|
-
}
|
|
2321
|
-
|
|
2322
|
-
function quotaResetAt(row: Record<string, unknown>): number | undefined {
|
|
2323
|
-
return normalizeResetAt(row.resetTime ?? row.resetAt ?? row.reset_time ?? row.reset_at);
|
|
2324
|
-
}
|
|
2325
|
-
|
|
2326
|
-
function isCanonicalKimiCodeBaseUrl(baseUrl: string): boolean {
|
|
2327
|
-
return normalizedBaseUrl(baseUrl) === KIMI_CODE_BASE_URL;
|
|
2328
|
-
}
|
|
2329
|
-
|
|
2330
|
-
function isCanonicalCommandCodeBaseUrl(baseUrl: string): boolean {
|
|
2331
|
-
const normalized = normalizedBaseUrl(baseUrl);
|
|
2332
|
-
// OAuth preset points at the API root; the Provider-API preset at /provider/v1.
|
|
2333
|
-
return normalized === COMMAND_CODE_BASE_URL || normalized === `${COMMAND_CODE_BASE_URL}/provider/v1`;
|
|
2334
|
-
}
|
|
2335
|
-
|
|
2336
|
-
/** Prefer the nested `data` shell when the outer object is only an envelope. */
|
|
2337
|
-
function unwrapKimiQuotaPayload(value: unknown): Record<string, unknown> | null {
|
|
2338
|
-
const body = asRecord(value);
|
|
2339
|
-
if (!body) return null;
|
|
2340
|
-
const nested = asRecord(body.data);
|
|
2341
|
-
if (!nested) return body;
|
|
2342
|
-
// A null/non-usable outer field is a placeholder, not data — an envelope like
|
|
2343
|
-
// { usage: null, data: { usage: {...} } } must still unwrap to the nested payload.
|
|
2344
|
-
const usable = (field: unknown): boolean => field !== undefined && field !== null;
|
|
2345
|
-
const outerHasUsage = usable(body.usage) || usable(body.limits) || usable(body.totalQuota);
|
|
2346
|
-
const nestedHasUsage = usable(nested.usage) || usable(nested.limits) || usable(nested.totalQuota);
|
|
2347
|
-
return !outerHasUsage && nestedHasUsage ? nested : body;
|
|
2348
|
-
}
|
|
2349
|
-
|
|
2350
|
-
function kimiLimitLabel(item: Record<string, unknown>, detail: Record<string, unknown>): string {
|
|
2351
|
-
return [item.name, item.title, item.scope, detail.name, detail.title]
|
|
2352
|
-
.filter((value): value is string => typeof value === "string")
|
|
2353
|
-
.join(" ")
|
|
2354
|
-
.toLowerCase();
|
|
2355
|
-
}
|
|
2356
|
-
|
|
2357
|
-
function parseKimiQuotaRow(value: unknown, resetFallback?: Record<string, unknown>): { percent: number; resetAt?: number } | null {
|
|
2358
|
-
const row = asRecord(value);
|
|
2359
|
-
if (!row) return null;
|
|
2360
|
-
const resetAt = quotaResetAt(row) ?? (resetFallback ? quotaResetAt(resetFallback) : undefined);
|
|
2361
|
-
const limit = toFiniteNumber(row.limit);
|
|
2362
|
-
if (limit !== undefined && limit > 0) {
|
|
2363
|
-
let used = toFiniteNumber(row.used);
|
|
2364
|
-
if (used === undefined) {
|
|
2365
|
-
const remaining = toFiniteNumber(row.remaining);
|
|
2366
|
-
if (remaining !== undefined) used = limit - remaining;
|
|
2367
|
-
}
|
|
2368
|
-
if (used !== undefined) {
|
|
2369
|
-
const percent = normalizePercent((used / limit) * 100);
|
|
2370
|
-
if (percent !== undefined) return { percent, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
2371
|
-
}
|
|
2372
|
-
}
|
|
2373
|
-
// Some payloads expose utilisation directly when limit/used arithmetic is absent.
|
|
2374
|
-
const direct = normalizePercent(row.utilization ?? row.percent ?? row.usedPercent ?? row.used_percent);
|
|
2375
|
-
return direct === undefined ? null : { percent: direct, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
2376
|
-
}
|
|
2377
|
-
|
|
2378
|
-
function isKimiFiveHourLimit(item: Record<string, unknown>, detail: Record<string, unknown>, window: Record<string, unknown>): boolean {
|
|
2379
|
-
const duration = toFiniteNumber(window.duration ?? item.duration ?? detail.duration);
|
|
2380
|
-
const unit = String(window.timeUnit ?? item.timeUnit ?? detail.timeUnit ?? "").toUpperCase();
|
|
2381
|
-
if ((unit.includes("MINUTE") && duration === 300) || (unit.includes("HOUR") && duration === 5)) return true;
|
|
2382
|
-
return /(^|\b)5\s*(?:h|hour)/.test(kimiLimitLabel(item, detail));
|
|
2383
|
-
}
|
|
2384
|
-
|
|
2385
|
-
function isKimiWeeklyLimit(item: Record<string, unknown>, detail: Record<string, unknown>, window: Record<string, unknown>): boolean {
|
|
2386
|
-
const duration = toFiniteNumber(window.duration ?? item.duration ?? detail.duration);
|
|
2387
|
-
const unit = String(window.timeUnit ?? item.timeUnit ?? detail.timeUnit ?? "").toUpperCase();
|
|
2388
|
-
if ((unit.includes("DAY") && duration === 7) || (unit.includes("HOUR") && duration === 168)) return true;
|
|
2389
|
-
return /weekly|7\s*(?:d|day)/.test(kimiLimitLabel(item, detail));
|
|
2390
|
-
}
|
|
2391
|
-
|
|
2392
|
-
function parseKimiQuotaPayload(value: unknown): ProviderQuota | null {
|
|
2393
|
-
const body = unwrapKimiQuotaPayload(value);
|
|
2394
|
-
if (!body) return null;
|
|
2395
|
-
let weekly = parseKimiQuotaRow(body.usage);
|
|
2396
|
-
const total = parseKimiQuotaRow(body.totalQuota);
|
|
2397
|
-
let fiveHour: { percent: number; resetAt?: number } | null = null;
|
|
2398
|
-
if (Array.isArray(body.limits)) {
|
|
2399
|
-
for (const rawItem of body.limits) {
|
|
2400
|
-
const item = asRecord(rawItem);
|
|
2401
|
-
if (!item) continue;
|
|
2402
|
-
const detail = asRecord(item.detail) ?? item;
|
|
2403
|
-
const window = asRecord(item.window) ?? {};
|
|
2404
|
-
if (!fiveHour && isKimiFiveHourLimit(item, detail, window)) {
|
|
2405
|
-
fiveHour = parseKimiQuotaRow(detail, window);
|
|
2406
|
-
}
|
|
2407
|
-
if (!weekly && isKimiWeeklyLimit(item, detail, window)) {
|
|
2408
|
-
weekly = parseKimiQuotaRow(detail, window);
|
|
2409
|
-
}
|
|
2410
|
-
if (fiveHour && weekly) break;
|
|
2411
|
-
}
|
|
2412
|
-
}
|
|
2413
|
-
const quota: ProviderQuota = {
|
|
2414
|
-
...(fiveHour ? {
|
|
2415
|
-
fiveHourPercent: fiveHour.percent,
|
|
2416
|
-
...(fiveHour.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}),
|
|
2417
|
-
} : {}),
|
|
2418
|
-
...(weekly ? {
|
|
2419
|
-
weeklyPercent: weekly.percent,
|
|
2420
|
-
...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}),
|
|
2421
|
-
} : {}),
|
|
2422
|
-
...(total ? { customWindows: [{ label: "Total subscription credits", percent: total.percent, ...(total.resetAt !== undefined ? { resetAt: total.resetAt } : {}) }] } : {}),
|
|
2423
|
-
updatedAt: Date.now(),
|
|
2424
|
-
};
|
|
2425
|
-
return hasQuotaRows(quota) ? quota : null;
|
|
2426
|
-
}
|
|
2427
|
-
|
|
2428
|
-
async function resolveKimiQuotaBearer(config: OcxProviderConfig, accountId?: string): Promise<string | null> {
|
|
2429
|
-
if (config.authMode === "oauth") {
|
|
2430
|
-
try {
|
|
2431
|
-
return accountId ? await getTokenForAccountQuotaProbe("kimi", accountId) : null;
|
|
2432
|
-
} catch {
|
|
2433
|
-
return null;
|
|
2434
|
-
}
|
|
2435
|
-
}
|
|
2436
|
-
// ACTIVE key only: silently walking apiKeyPool when the primary env reference is
|
|
2437
|
-
// unresolved would render a quota bar for a DIFFERENT account than the one routing
|
|
2438
|
-
// requests — a wrong meter is worse than no meter.
|
|
2439
|
-
const primary = resolveProviderApiKey(config.apiKey)?.trim();
|
|
2440
|
-
return primary || null;
|
|
2441
|
-
}
|
|
2442
|
-
|
|
2443
|
-
async function fetchKimiQuota(provider: string, config: OcxProviderConfig, accessToken: string): Promise<ProviderQuotaReport | null> {
|
|
2444
|
-
// Never release credentials to a user-edited or lookalike provider host.
|
|
2445
|
-
if (!isCanonicalKimiCodeBaseUrl(config.baseUrl)) return null;
|
|
2446
|
-
if (!accessToken) return null;
|
|
2447
|
-
const response = await fetch(KIMI_CODE_USAGE_URL, {
|
|
2448
|
-
headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
|
|
2449
|
-
redirect: "error",
|
|
2450
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
2451
|
-
});
|
|
2452
|
-
if (!response.ok) return null;
|
|
2453
|
-
const quota = parseKimiQuotaPayload(await readQuotaJson(response));
|
|
2454
|
-
return quota ? keyReport(provider, "kimi:usages", quota, config, accessToken, quota) : null;
|
|
2455
|
-
}
|
|
2456
|
-
|
|
2457
|
-
/**
|
|
2458
|
-
* Command Code rolling window: `{ cap, used, resetAt }` off /alpha/billing/credits,
|
|
2459
|
-
* normalized to a percent with an optional reset timestamp.
|
|
2460
|
-
*/
|
|
2461
|
-
function parseCommandCodeWindow(value: unknown): { percent: number; resetAt?: number } | null {
|
|
2462
|
-
const row = asRecord(value);
|
|
2463
|
-
if (!row) return null;
|
|
2464
|
-
const cap = toFiniteNumber(row.cap);
|
|
2465
|
-
const used = toFiniteNumber(row.used);
|
|
2466
|
-
if (cap === undefined || used === undefined || cap <= 0 || used < 0) return null;
|
|
2467
|
-
const percent = normalizePercent((used / cap) * 100);
|
|
2468
|
-
if (percent === undefined) return null;
|
|
2469
|
-
const resetAt = quotaResetAt(row);
|
|
2470
|
-
return { percent, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
2471
|
-
}
|
|
2472
|
-
|
|
2473
|
-
/** Soft-fail GET returning a parsed record, or null when unavailable. */
|
|
2474
|
-
async function fetchCommandCodeJson(url: string, bearer: string): Promise<Record<string, unknown> | null> {
|
|
2475
|
-
try {
|
|
2476
|
-
const response = await fetch(url, {
|
|
2477
|
-
headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` },
|
|
2478
|
-
redirect: "error",
|
|
2479
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
2480
|
-
});
|
|
2481
|
-
if (!response.ok) return null;
|
|
2482
|
-
return asRecord(await readQuotaJson(response));
|
|
2483
|
-
} catch {
|
|
2484
|
-
return null;
|
|
2485
|
-
}
|
|
2486
|
-
}
|
|
2487
|
-
|
|
2488
|
-
/**
|
|
2489
|
-
* Soft-fail period spend (used) against the remaining credit pools → creditsUsd.
|
|
2490
|
-
* Period scoping: `since=<currentPeriodStart>` keeps spend aligned with the
|
|
2491
|
-
* pools' billing cycle, and `currentPeriodEnd` becomes expiresAt.
|
|
2492
|
-
*/
|
|
2493
|
-
async function fetchCommandCodeSpend(
|
|
2494
|
-
bearer: string,
|
|
2495
|
-
credits: Record<string, unknown> | null,
|
|
2496
|
-
orgQuery: string,
|
|
2497
|
-
): Promise<ProviderQuotaCreditsUsd | undefined> {
|
|
2498
|
-
if (!credits) return undefined;
|
|
2499
|
-
const subscriptionBody = await fetchCommandCodeJson(`${COMMAND_CODE_SUBSCRIPTIONS_URL}${orgQuery}`, bearer);
|
|
2500
|
-
const subscription = asRecord(subscriptionBody?.data) ?? subscriptionBody;
|
|
2501
|
-
const periodStart = typeof subscription?.currentPeriodStart === "string" ? subscription.currentPeriodStart.trim() : "";
|
|
2502
|
-
// Unscoped /usage/summary is lifetime spend; mixing it with current-cycle
|
|
2503
|
-
// remaining pools produces a wrong percent. Omit creditsUsd until a period exists.
|
|
2504
|
-
if (!periodStart) return undefined;
|
|
2505
|
-
const sinceQuery = `${orgQuery ? "&" : "?"}since=${encodeURIComponent(periodStart)}`;
|
|
2506
|
-
const expiresAt = normalizeResetAt(subscription?.currentPeriodEnd);
|
|
2507
|
-
const summaryBody = await fetchCommandCodeJson(`${COMMAND_CODE_USAGE_URL}${orgQuery}${sinceQuery}`, bearer);
|
|
2508
|
-
const summary = asRecord(summaryBody?.data) ?? summaryBody;
|
|
2509
|
-
const used = toFiniteNumber(summary?.totalCost) ?? toFiniteNumber(summary?.totalMonthlyCredits);
|
|
2510
|
-
if (used === undefined || used < 0) return undefined;
|
|
2511
|
-
const pools = [credits.monthlyCredits, credits.purchasedCredits, credits.freeCredits]
|
|
2512
|
-
.map(value => toFiniteNumber(value))
|
|
2513
|
-
.filter((value): value is number => value !== undefined);
|
|
2514
|
-
// Field presence is what separates a real balance from absent data: an exhausted
|
|
2515
|
-
// all-zero account still reports remaining=0, while no remaining-credit field at
|
|
2516
|
-
// all means there is nothing to meter.
|
|
2517
|
-
if (pools.length === 0) return undefined;
|
|
2518
|
-
const remaining = pools.reduce((sum, value) => sum + Math.max(0, value ?? 0), 0);
|
|
2519
|
-
const limit = used + remaining;
|
|
2520
|
-
const percent = normalizePercent(limit > 0 ? (used / limit) * 100 : 0);
|
|
2521
|
-
// Purchased credits roll over past the subscription period end, so an expiry is
|
|
2522
|
-
// only truthful when the aggregate contains no non-expiring purchased pool.
|
|
2523
|
-
const purchased = toFiniteNumber(credits.purchasedCredits) ?? 0;
|
|
2524
|
-
return percent === undefined
|
|
2525
|
-
? undefined
|
|
2526
|
-
: {
|
|
2527
|
-
used,
|
|
2528
|
-
limit,
|
|
2529
|
-
remaining,
|
|
2530
|
-
percent,
|
|
2531
|
-
...(expiresAt !== undefined && purchased <= 0 ? { expiresAt } : {}),
|
|
2532
|
-
};
|
|
2533
|
-
}
|
|
2534
|
-
|
|
2535
|
-
/** OAuth access token or ACTIVE Provider-API key for the Command Code quota probe. */
|
|
2536
|
-
async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig, accountId?: string): Promise<string | null> {
|
|
2537
|
-
if (config.authMode === "oauth") {
|
|
2538
|
-
try {
|
|
2539
|
-
return accountId ? await getTokenForAccountQuotaProbe("command-code", accountId) : null;
|
|
2540
|
-
} catch {
|
|
2541
|
-
return null;
|
|
2542
|
-
}
|
|
2543
|
-
}
|
|
2544
|
-
// ACTIVE key only: a quota bar for a different account than the one routing
|
|
2545
|
-
// requests is a wrong meter, not a helpful one.
|
|
2546
|
-
return resolveProviderApiKey(config.apiKey)?.trim() || null;
|
|
2547
|
-
}
|
|
2548
|
-
|
|
2549
|
-
/**
|
|
2550
|
-
* Command Code `GET /alpha/billing/credits` — the same Bearer surface the CLI's
|
|
2551
|
-
* usage view uses (windowLimits.fiveHour / windowLimits.weekly), plus soft
|
|
2552
|
-
* whoami (team orgId scoping) and subscription-scoped spend for creditsUsd.
|
|
2553
|
-
*/
|
|
2554
|
-
async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig, bearer: string): Promise<ProviderQuotaProbeResult> {
|
|
2555
|
-
// Never release credentials to a user-edited or lookalike provider host.
|
|
2556
|
-
if (!isCanonicalCommandCodeBaseUrl(config.baseUrl)) return null;
|
|
2557
|
-
if (!bearer) return null;
|
|
2558
|
-
const whoamiBody = await fetchCommandCodeJson(COMMAND_CODE_WHOAMI_URL, bearer);
|
|
2559
|
-
const whoami = asRecord(whoamiBody?.data) ?? whoamiBody;
|
|
2560
|
-
const org = asRecord(whoami?.org);
|
|
2561
|
-
const orgId = typeof org?.id === "string" && org.id.trim() ? org.id.trim() : null;
|
|
2562
|
-
const orgQuery = orgId ? `?orgId=${encodeURIComponent(orgId)}` : "";
|
|
2563
|
-
const response = await fetch(`${COMMAND_CODE_CREDITS_URL}${orgQuery}`, {
|
|
2564
|
-
headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` },
|
|
2565
|
-
redirect: "error",
|
|
2566
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
2567
|
-
});
|
|
2568
|
-
if (!response.ok) {
|
|
2569
|
-
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
2570
|
-
? TERMINAL_QUOTA_FAILURE
|
|
2571
|
-
: null;
|
|
2572
|
-
}
|
|
2573
|
-
const raw = asRecord(await readQuotaJson(response));
|
|
2574
|
-
const body = asRecord(raw?.data) ?? raw;
|
|
2575
|
-
const credits = asRecord(body?.credits);
|
|
2576
|
-
const limits = asRecord(body?.windowLimits);
|
|
2577
|
-
if (!credits && !limits) return null;
|
|
2578
|
-
const fiveHour = parseCommandCodeWindow(limits?.fiveHour);
|
|
2579
|
-
const weekly = parseCommandCodeWindow(limits?.weekly);
|
|
2580
|
-
const creditsUsd = await fetchCommandCodeSpend(bearer, credits, orgQuery);
|
|
2581
|
-
const quota: ProviderQuota = {
|
|
2582
|
-
...(fiveHour ? {
|
|
2583
|
-
fiveHourPercent: fiveHour.percent,
|
|
2584
|
-
...(fiveHour.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}),
|
|
2585
|
-
} : {}),
|
|
2586
|
-
...(weekly ? {
|
|
2587
|
-
weeklyPercent: weekly.percent,
|
|
2588
|
-
...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}),
|
|
2589
|
-
} : {}),
|
|
2590
|
-
...(creditsUsd ? { creditsUsd } : {}),
|
|
2591
|
-
updatedAt: Date.now(),
|
|
2592
|
-
};
|
|
2593
|
-
// Rolling windows and the credit balance both gate inference on this bearer.
|
|
2594
|
-
return keyReport(provider, "command-code:credits", quota, config, bearer, quota);
|
|
2595
|
-
}
|
|
2596
|
-
|
|
2597
|
-
/** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */
|
|
2598
|
-
async function fetchCursorQuota(provider: string, accessToken: string): Promise<ProviderQuotaReport | null> {
|
|
2599
|
-
|
|
2600
|
-
const authHeaders = {
|
|
2601
|
-
Accept: "application/json",
|
|
2602
|
-
Authorization: `Bearer ${accessToken}`,
|
|
2603
|
-
"User-Agent": "opencodex-quota",
|
|
2604
|
-
} as const;
|
|
2605
|
-
|
|
2606
|
-
// Prefer dashboard period usage (Pro/Team/Ultra spend allowance in USD cents).
|
|
2607
|
-
// Field names follow Cursor's Connect RPC shape (limit/remaining/includedSpend), not usedCents.
|
|
2608
|
-
try {
|
|
2609
|
-
const periodRes = await fetch("https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", {
|
|
2610
|
-
method: "POST",
|
|
2611
|
-
redirect: "error",
|
|
2612
|
-
headers: {
|
|
2613
|
-
...authHeaders,
|
|
2614
|
-
"Content-Type": "application/json",
|
|
2615
|
-
"Connect-Protocol-Version": "1",
|
|
2616
|
-
},
|
|
2617
|
-
body: "{}",
|
|
2618
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
2619
|
-
});
|
|
2620
|
-
if (periodRes.ok) {
|
|
2621
|
-
const body = asRecord(await readQuotaJson(periodRes));
|
|
2622
|
-
const planUsage = asRecord(body?.planUsage);
|
|
2623
|
-
if (planUsage) {
|
|
2624
|
-
const resetAt = normalizeResetAt(body?.billingCycleEnd ?? planUsage.billingCycleEnd ?? body?.periodEnd);
|
|
2625
|
-
|
|
2626
|
-
// Primary meter: overall included allowance (Cursor Settings → Usage total %).
|
|
2627
|
-
// autoPercentUsed / apiPercentUsed are secondary pools and must not replace the total.
|
|
2628
|
-
const limit = toFiniteNumber(planUsage.limit ?? planUsage.limitCents ?? planUsage.totalLimitCents);
|
|
2629
|
-
const remaining = toFiniteNumber(planUsage.remaining ?? planUsage.remainingCents);
|
|
2630
|
-
const includedSpend = toFiniteNumber(planUsage.includedSpend ?? planUsage.usedCents ?? planUsage.used);
|
|
2631
|
-
const totalSpend = toFiniteNumber(planUsage.totalSpend);
|
|
2632
|
-
let used: number | undefined;
|
|
2633
|
-
if (includedSpend !== undefined) used = includedSpend;
|
|
2634
|
-
else if (limit !== undefined && remaining !== undefined) used = Math.max(0, limit - remaining);
|
|
2635
|
-
else if (totalSpend !== undefined) used = totalSpend;
|
|
2636
|
-
const totalPercent = normalizePercent(planUsage.totalPercentUsed ?? planUsage.percentUsed)
|
|
2637
|
-
?? (limit !== undefined && limit > 0 && used !== undefined
|
|
2638
|
-
? normalizePercent((used / limit) * 100)
|
|
2639
|
-
: undefined);
|
|
2640
|
-
|
|
2641
|
-
const autoPercent = normalizePercent(planUsage.autoPercentUsed);
|
|
2642
|
-
const apiPercent = normalizePercent(planUsage.apiPercentUsed);
|
|
2643
|
-
const customWindows: ProviderQuotaWindow[] = [];
|
|
2644
|
-
if (autoPercent !== undefined) {
|
|
2645
|
-
customWindows.push({
|
|
2646
|
-
label: "First-party models",
|
|
2647
|
-
percent: autoPercent,
|
|
2648
|
-
...(resetAt !== undefined ? { resetAt } : {}),
|
|
2649
|
-
});
|
|
2650
|
-
}
|
|
2651
|
-
if (apiPercent !== undefined) {
|
|
2652
|
-
customWindows.push({
|
|
2653
|
-
label: "API usage",
|
|
2654
|
-
percent: apiPercent,
|
|
2655
|
-
...(resetAt !== undefined ? { resetAt } : {}),
|
|
2656
|
-
});
|
|
2657
|
-
}
|
|
2658
|
-
|
|
2659
|
-
if (totalPercent !== undefined || customWindows.length > 0) {
|
|
2660
|
-
const built = report(provider, "cursor:period-usage", {
|
|
2661
|
-
...(totalPercent !== undefined ? {
|
|
2662
|
-
monthlyPercent: totalPercent,
|
|
2663
|
-
...(resetAt !== undefined ? { monthlyResetAt: resetAt } : {}),
|
|
2664
|
-
} : {}),
|
|
2665
|
-
...(customWindows.length > 0 ? { customWindows } : {}),
|
|
2666
|
-
updatedAt: Date.now(),
|
|
2667
|
-
});
|
|
2668
|
-
if (built) return { ...built, reverseEngineered: true };
|
|
2669
|
-
}
|
|
2670
|
-
}
|
|
2671
|
-
}
|
|
2672
|
-
} catch {
|
|
2673
|
-
/* fall through */
|
|
2674
|
-
}
|
|
2675
|
-
|
|
2676
|
-
// /api/usage/summary — same host, sometimes richer than /auth/usage for Team plans.
|
|
2677
|
-
try {
|
|
2678
|
-
const summaryRes = await fetch("https://api2.cursor.sh/api/usage/summary", {
|
|
2679
|
-
headers: authHeaders,
|
|
2680
|
-
redirect: "error",
|
|
2681
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
2682
|
-
});
|
|
2683
|
-
if (summaryRes.ok) {
|
|
2684
|
-
const body = asRecord(await readQuotaJson(summaryRes));
|
|
2685
|
-
const individual = asRecord(body?.individualUsage);
|
|
2686
|
-
const plan = asRecord(individual?.plan);
|
|
2687
|
-
if (plan) {
|
|
2688
|
-
const used = toFiniteNumber(plan.used);
|
|
2689
|
-
const limit = toFiniteNumber(plan.limit);
|
|
2690
|
-
const percent = normalizePercent(plan.totalPercentUsed)
|
|
2691
|
-
?? (used !== undefined && limit !== undefined && limit > 0
|
|
2692
|
-
? normalizePercent((used / limit) * 100)
|
|
2693
|
-
: undefined);
|
|
2694
|
-
if (percent !== undefined) {
|
|
2695
|
-
const built = report(provider, "cursor:usage-summary", {
|
|
2696
|
-
monthlyPercent: percent,
|
|
2697
|
-
monthlyResetAt: normalizeResetAt(body?.billingCycleEnd),
|
|
2698
|
-
updatedAt: Date.now(),
|
|
2699
|
-
});
|
|
2700
|
-
if (built) return { ...built, reverseEngineered: true };
|
|
2701
|
-
}
|
|
2702
|
-
}
|
|
2703
|
-
}
|
|
2704
|
-
} catch {
|
|
2705
|
-
/* fall through to /auth/usage */
|
|
2706
|
-
}
|
|
2707
|
-
|
|
2708
|
-
const response = await fetch("https://api2.cursor.sh/auth/usage", {
|
|
2709
|
-
headers: authHeaders,
|
|
2710
|
-
redirect: "error",
|
|
2711
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
2712
|
-
});
|
|
2713
|
-
if (!response.ok) return null;
|
|
2714
|
-
const body = asRecord(await readQuotaJson(response));
|
|
2715
|
-
if (!body) return null;
|
|
2716
|
-
|
|
2717
|
-
// Prefer the gpt-4 bucket (historical "fast requests"); else first model with used+limit.
|
|
2718
|
-
let used: number | undefined;
|
|
2719
|
-
let limit: number | undefined;
|
|
2720
|
-
const gpt4 = asRecord(body["gpt-4"]);
|
|
2721
|
-
if (gpt4) {
|
|
2722
|
-
used = toFiniteNumber(gpt4.numRequests ?? gpt4.used);
|
|
2723
|
-
limit = toFiniteNumber(gpt4.maxRequestUsage ?? gpt4.limit ?? gpt4.maxRequests);
|
|
2724
|
-
}
|
|
2725
|
-
if (used === undefined || limit === undefined || limit <= 0) {
|
|
2726
|
-
for (const [key, value] of Object.entries(body)) {
|
|
2727
|
-
if (key === "startOfMonth" || key === "billingCycleStart") continue;
|
|
2728
|
-
const bucket = asRecord(value);
|
|
2729
|
-
if (!bucket) continue;
|
|
2730
|
-
const bucketUsed = toFiniteNumber(bucket.numRequests ?? bucket.used);
|
|
2731
|
-
const bucketLimit = toFiniteNumber(bucket.maxRequestUsage ?? bucket.limit ?? bucket.maxRequests);
|
|
2732
|
-
if (bucketUsed !== undefined && bucketLimit !== undefined && bucketLimit > 0) {
|
|
2733
|
-
used = bucketUsed;
|
|
2734
|
-
limit = bucketLimit;
|
|
2735
|
-
break;
|
|
2736
|
-
}
|
|
2737
|
-
}
|
|
2738
|
-
}
|
|
2739
|
-
if (used === undefined || limit === undefined || limit <= 0) return null;
|
|
2740
|
-
const percent = normalizePercent((used / limit) * 100);
|
|
2741
|
-
if (percent === undefined) return null;
|
|
2742
|
-
const startOfMonth = normalizeResetAt(body.startOfMonth ?? body.billingCycleStart);
|
|
2743
|
-
// Next reset = same day next month, computed in UTC to avoid timezone-shifted rollover.
|
|
2744
|
-
const monthlyResetAt = startOfMonth !== undefined
|
|
2745
|
-
? (() => {
|
|
2746
|
-
const start = new Date(startOfMonth);
|
|
2747
|
-
return Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, start.getUTCDate());
|
|
2748
|
-
})()
|
|
2749
|
-
: undefined;
|
|
2750
|
-
const built = report(provider, "cursor:auth-usage", {
|
|
2751
|
-
monthlyPercent: percent,
|
|
2752
|
-
...(monthlyResetAt !== undefined ? { monthlyResetAt } : {}),
|
|
2753
|
-
updatedAt: Date.now(),
|
|
2754
|
-
});
|
|
2755
|
-
return built ? { ...built, reverseEngineered: true } : null;
|
|
2756
|
-
}
|
|
2757
|
-
|
|
2758
|
-
function quotaInfoEntries(modelInfo: Record<string, unknown>): Record<string, unknown>[] {
|
|
2759
|
-
const entries: Record<string, unknown>[] = [];
|
|
2760
|
-
const add = (value: unknown, tier?: string) => {
|
|
2761
|
-
const rec = asRecord(value);
|
|
2762
|
-
if (!rec) return;
|
|
2763
|
-
entries.push(tier ? { ...rec, tier } : rec);
|
|
2764
|
-
};
|
|
2765
|
-
const addArray = (value: unknown) => {
|
|
2766
|
-
if (!Array.isArray(value)) return;
|
|
2767
|
-
for (const entry of value) add(entry);
|
|
2768
|
-
};
|
|
2769
|
-
|
|
2770
|
-
if (Array.isArray(modelInfo.quotaInfo)) addArray(modelInfo.quotaInfo);
|
|
2771
|
-
else add(modelInfo.quotaInfo);
|
|
2772
|
-
addArray(modelInfo.quotaInfos);
|
|
2773
|
-
|
|
2774
|
-
const byTier = asRecord(modelInfo.quotaInfoByTier);
|
|
2775
|
-
if (byTier) {
|
|
2776
|
-
for (const [tier, value] of Object.entries(byTier)) {
|
|
2777
|
-
if (Array.isArray(value)) {
|
|
2778
|
-
for (const entry of value) add(entry, tier);
|
|
2779
|
-
} else {
|
|
2780
|
-
add(value, tier);
|
|
2781
|
-
}
|
|
2782
|
-
}
|
|
2783
|
-
}
|
|
2784
|
-
return entries;
|
|
2785
|
-
}
|
|
2786
|
-
|
|
2787
|
-
function classifyAntigravityFamily(modelId: string, modelInfo: Record<string, unknown>, quotaInfo: Record<string, unknown>): "Gem" | "Cla" | null {
|
|
2788
|
-
const displayName = typeof modelInfo.displayName === "string" ? modelInfo.displayName : "";
|
|
2789
|
-
const tier = typeof quotaInfo.tier === "string" ? quotaInfo.tier : "";
|
|
2790
|
-
const haystack = `${modelId} ${displayName} ${tier}`.toLowerCase();
|
|
2791
|
-
if (haystack.includes("gemini")) return "Gem";
|
|
2792
|
-
if (haystack.includes("claude") || haystack.includes("opus") || haystack.includes("sonnet") || haystack.includes("gpt-oss") || haystack.includes("gpt_oss")) return "Cla";
|
|
2793
|
-
return null;
|
|
2794
|
-
}
|
|
2795
|
-
|
|
2796
|
-
function antigravityUsedPercent(quotaInfo: Record<string, unknown>): number | undefined {
|
|
2797
|
-
const target = asRecord(quotaInfo.remaining) ?? quotaInfo;
|
|
2798
|
-
const remaining = normalizePercent(toFiniteNumber(target.remainingFraction) !== undefined
|
|
2799
|
-
? toFiniteNumber(target.remainingFraction)! * 100
|
|
2800
|
-
: toFiniteNumber(target.remainingPercentage) !== undefined
|
|
2801
|
-
? toFiniteNumber(target.remainingPercentage)! * 100
|
|
2802
|
-
: undefined);
|
|
2803
|
-
if (remaining === undefined) return undefined;
|
|
2804
|
-
return normalizePercent(100 - remaining);
|
|
2805
|
-
}
|
|
2806
|
-
|
|
2807
|
-
/** Gem/Cla windows from a `fetchAvailableModels` body; shared by the provider and account probes. */
|
|
2808
|
-
function antigravityWindowsFromModels(body: Record<string, unknown> | null): ProviderQuotaWindow[] {
|
|
2809
|
-
const models = asRecord(body?.models);
|
|
2810
|
-
if (!models) return [];
|
|
2811
|
-
|
|
2812
|
-
const windows = new Map<string, ProviderQuotaWindow>();
|
|
2813
|
-
for (const [modelId, rawModelInfo] of Object.entries(models)) {
|
|
2814
|
-
const modelInfo = asRecord(rawModelInfo);
|
|
2815
|
-
if (!modelInfo) continue;
|
|
2816
|
-
for (const quotaInfo of quotaInfoEntries(modelInfo)) {
|
|
2817
|
-
const label = classifyAntigravityFamily(modelId, modelInfo, quotaInfo);
|
|
2818
|
-
if (!label || windows.has(label)) continue;
|
|
2819
|
-
const percent = antigravityUsedPercent(quotaInfo);
|
|
2820
|
-
if (percent === undefined) continue;
|
|
2821
|
-
windows.set(label, {
|
|
2822
|
-
label,
|
|
2823
|
-
percent,
|
|
2824
|
-
...(normalizeResetAt(quotaInfo.resetTime) !== undefined ? { resetAt: normalizeResetAt(quotaInfo.resetTime) } : {}),
|
|
2825
|
-
});
|
|
2826
|
-
}
|
|
2827
|
-
}
|
|
2828
|
-
|
|
2829
|
-
const customWindows = ["Gem", "Cla"].flatMap(label => {
|
|
2830
|
-
const window = windows.get(label);
|
|
2831
|
-
return window ? [window] : [];
|
|
2832
|
-
});
|
|
2833
|
-
return customWindows;
|
|
2834
|
-
}
|
|
2835
|
-
|
|
2836
|
-
/**
|
|
2837
|
-
* Parse Google Antigravity quota from `v1internal:retrieveUserQuotaSummary`.
|
|
2838
|
-
* Groups contain Gemini models and Claude/3P models, each with 5h and weekly limit buckets.
|
|
2839
|
-
*/
|
|
2840
|
-
function parseAntigravityQuotaSummary(body: Record<string, unknown> | null): ProviderQuota | null {
|
|
2841
|
-
const groups = Array.isArray(body?.groups) ? (body.groups as unknown[]) : [];
|
|
2842
|
-
if (groups.length === 0) return null;
|
|
2843
|
-
|
|
2844
|
-
const customWindowsMap = new Map<string, ProviderQuotaWindow>();
|
|
2845
|
-
|
|
2846
|
-
for (const rawGroup of groups) {
|
|
2847
|
-
const group = asRecord(rawGroup);
|
|
2848
|
-
if (!group) continue;
|
|
2849
|
-
const groupName = `${typeof group.displayName === "string" ? group.displayName : ""} ${typeof group.description === "string" ? group.description : ""}`.toLowerCase();
|
|
2850
|
-
const isGemini = groupName.includes("gemini");
|
|
2851
|
-
const isClaude = groupName.includes("claude") || groupName.includes("3p") || groupName.includes("gpt");
|
|
2852
|
-
|
|
2853
|
-
const buckets = Array.isArray(group.buckets) ? (group.buckets as unknown[]) : [];
|
|
2854
|
-
for (const rawBucket of buckets) {
|
|
2855
|
-
const bucket = asRecord(rawBucket);
|
|
2856
|
-
if (!bucket) continue;
|
|
2857
|
-
const windowStr = `${typeof bucket.window === "string" ? bucket.window : ""} ${typeof bucket.bucketId === "string" ? bucket.bucketId : ""} ${typeof bucket.displayName === "string" ? bucket.displayName : ""}`.toLowerCase();
|
|
2858
|
-
const percent = antigravityUsedPercent(bucket);
|
|
2859
|
-
if (percent === undefined) continue;
|
|
2860
|
-
const resetAt = normalizeResetAt(bucket.resetTime);
|
|
2861
|
-
|
|
2862
|
-
const isWeekly = windowStr.includes("week");
|
|
2863
|
-
const is5h = windowStr.includes("5h") || windowStr.includes("five");
|
|
2864
|
-
|
|
2865
|
-
if (isGemini) {
|
|
2866
|
-
const label = is5h ? "Gem" : isWeekly ? "Gem (Weekly)" : "";
|
|
2867
|
-
if (label && !customWindowsMap.has(label)) {
|
|
2868
|
-
customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) });
|
|
2869
|
-
}
|
|
2870
|
-
} else if (isClaude) {
|
|
2871
|
-
const label = is5h ? "Cla" : isWeekly ? "Cla (Weekly)" : "";
|
|
2872
|
-
if (label && !customWindowsMap.has(label)) {
|
|
2873
|
-
customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) });
|
|
2874
|
-
}
|
|
2875
|
-
} else {
|
|
2876
|
-
const baseLabel = typeof group.displayName === "string" ? group.displayName : "Other";
|
|
2877
|
-
const label = isWeekly ? `${baseLabel} (Weekly)` : baseLabel;
|
|
2878
|
-
if (!customWindowsMap.has(label)) {
|
|
2879
|
-
customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) });
|
|
2880
|
-
}
|
|
2881
|
-
}
|
|
2882
|
-
}
|
|
2883
|
-
}
|
|
2884
|
-
|
|
2885
|
-
const PREFERRED_ORDER = ["Gem", "Gem (Weekly)", "Cla", "Cla (Weekly)"];
|
|
2886
|
-
const customWindows = Array.from(customWindowsMap.values()).sort((a, b) => {
|
|
2887
|
-
const ia = PREFERRED_ORDER.indexOf(a.label);
|
|
2888
|
-
const ib = PREFERRED_ORDER.indexOf(b.label);
|
|
2889
|
-
if (ia !== -1 && ib !== -1) return ia - ib;
|
|
2890
|
-
if (ia !== -1) return -1;
|
|
2891
|
-
if (ib !== -1) return 1;
|
|
2892
|
-
return a.label.localeCompare(b.label);
|
|
2893
|
-
});
|
|
2894
|
-
|
|
2895
|
-
if (customWindows.length === 0) {
|
|
2896
|
-
return null;
|
|
2897
|
-
}
|
|
2898
|
-
|
|
2899
|
-
return {
|
|
2900
|
-
customWindows,
|
|
2901
|
-
updatedAt: Date.now(),
|
|
2902
|
-
};
|
|
2903
|
-
}
|
|
2904
|
-
|
|
2905
|
-
const ANTIGRAVITY_ACCOUNT_QUOTA_BASE = "https://daily-cloudcode-pa.googleapis.com";
|
|
2906
|
-
const ANTIGRAVITY_QUOTA_SUMMARY_URL = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:retrieveUserQuotaSummary`;
|
|
2907
|
-
const ANTIGRAVITY_QUOTA_MODELS_URL = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:fetchAvailableModels`;
|
|
2908
|
-
|
|
2909
|
-
/** Only these fixed accounting destinations may use transparent Fake-IP DNS. */
|
|
2910
|
-
export function isCanonicalAntigravityQuotaUrl(name: string, url: string): boolean {
|
|
2911
|
-
return name === "google-antigravity"
|
|
2912
|
-
&& (url === ANTIGRAVITY_QUOTA_SUMMARY_URL || url === ANTIGRAVITY_QUOTA_MODELS_URL);
|
|
2913
|
-
}
|
|
2914
|
-
|
|
2915
|
-
let antigravityOutboundDependencies: ProviderOutboundDependencies = {
|
|
2916
|
-
isCanonicalUrl: isCanonicalAntigravityQuotaUrl,
|
|
2917
|
-
};
|
|
2918
|
-
|
|
2919
|
-
/** Test seam: inject resolver/pinned transport for provider and per-account probes. */
|
|
2920
|
-
export function setAntigravityAccountQuotaTransportForTests(dependencies: ProviderOutboundDependencies | null): void {
|
|
2921
|
-
antigravityOutboundDependencies = { ...dependencies, isCanonicalUrl: isCanonicalAntigravityQuotaUrl };
|
|
2922
|
-
}
|
|
2923
|
-
|
|
2924
|
-
/**
|
|
2925
|
-
* Per-account Antigravity quota (#1082). Always probes Google's own Cloud Code Assist host
|
|
2926
|
-
* through the pinned provider-outbound transport: a configured `baseUrl` is a routing choice
|
|
2927
|
-
* for requests, not a second source of Google's accounting for a stored credential, and fixing
|
|
2928
|
-
* the destination keeps the `provider\0accountId` cache identity exact across config changes.
|
|
2929
|
-
* A redirect or non-2xx yields null (unavailable), never a partial row.
|
|
2930
|
-
*/
|
|
2931
|
-
type AntigravityQuotaProbeResult =
|
|
2932
|
-
| { kind: "available"; quota: ProviderQuota; source: "google-antigravity:retrieveUserQuotaSummary" | "google-antigravity:fetchAvailableModels" }
|
|
2933
|
-
| { kind: "unavailable"; failure: QuotaFailureCode; legacy: { kind: "null" } | { kind: "throw"; error: unknown } };
|
|
2934
|
-
|
|
2935
|
-
function quotaTransportFailure(error: unknown): QuotaFailureCode {
|
|
2936
|
-
if (error instanceof ProviderOutboundPolicyError) return "destination_blocked";
|
|
2937
|
-
if (error instanceof DestinationDnsResolutionError) return "dns_failed";
|
|
2938
|
-
if (error instanceof PinnedHttpError) return error.code === "output_byte_limit" ? "response_unusable" : "timeout";
|
|
2939
|
-
if (error instanceof DOMException && error.name === "TimeoutError") return "timeout";
|
|
2940
|
-
return "transport_error";
|
|
2941
|
-
}
|
|
2942
|
-
|
|
2943
|
-
function quotaHttpFailure(status: number): QuotaFailureCode {
|
|
2944
|
-
if (status >= 300 && status < 400) return "redirect_blocked";
|
|
2945
|
-
if (status === 401 || status === 403) return "access_denied";
|
|
2946
|
-
if (status === 429) return "rate_limited";
|
|
2947
|
-
return "upstream_error";
|
|
2948
|
-
}
|
|
2949
|
-
|
|
2950
|
-
function unavailableAntigravityQuota(failure: QuotaFailureCode): AntigravityQuotaProbeResult {
|
|
2951
|
-
return { kind: "unavailable", failure, legacy: { kind: "null" } };
|
|
2952
|
-
}
|
|
2953
|
-
|
|
2954
|
-
/**
|
|
2955
|
-
* Prefer a summary network-policy diagnosis over a vaguer fallback. A blocked
|
|
2956
|
-
* destination is an actionable local-network fact, while "upstream_error" tells
|
|
2957
|
-
* the operator to go look at Google. A successful models probe still clears
|
|
2958
|
-
* the first failure completely.
|
|
2959
|
-
*/
|
|
2960
|
-
function antigravityUnavailableFailure(
|
|
2961
|
-
summaryFailure: QuotaFailureCode | undefined,
|
|
2962
|
-
fallbackFailure: QuotaFailureCode,
|
|
2963
|
-
): QuotaFailureCode {
|
|
2964
|
-
if (
|
|
2965
|
-
(summaryFailure === "destination_blocked" || summaryFailure === "dns_failed")
|
|
2966
|
-
&& fallbackFailure !== "destination_blocked"
|
|
2967
|
-
&& fallbackFailure !== "dns_failed"
|
|
2968
|
-
) {
|
|
2969
|
-
return summaryFailure;
|
|
2970
|
-
}
|
|
2971
|
-
return fallbackFailure;
|
|
2972
|
-
}
|
|
2973
|
-
|
|
2974
|
-
async function probeAntigravityUsageQuota(accessToken: string, projectId: string): Promise<AntigravityQuotaProbeResult> {
|
|
2975
|
-
const fetchQuota = (url: string) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, {
|
|
2976
|
-
headers: {
|
|
2977
|
-
Accept: "application/json", "Content-Type": "application/json",
|
|
2978
|
-
"User-Agent": antigravityUserAgent(), Authorization: `Bearer ${accessToken}`,
|
|
2979
|
-
},
|
|
2980
|
-
body: JSON.stringify({ project: projectId }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
2981
|
-
}, antigravityOutboundDependencies);
|
|
2982
|
-
let summaryFailure: QuotaFailureCode | undefined;
|
|
2983
|
-
try {
|
|
2984
|
-
const response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL);
|
|
2985
|
-
if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_SUMMARY_URL)) return unavailableAntigravityQuota("redirect_blocked");
|
|
2986
|
-
if (response.status === 401 || response.status === 403) return unavailableAntigravityQuota("access_denied");
|
|
2987
|
-
if (response.ok) {
|
|
2988
|
-
const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(response)));
|
|
2989
|
-
if (quota) return { kind: "available", quota, source: "google-antigravity:retrieveUserQuotaSummary" };
|
|
2990
|
-
}
|
|
2991
|
-
} catch (error) {
|
|
2992
|
-
// Existing behavior: summary transport/parse failure may recover through the models probe.
|
|
2993
|
-
summaryFailure = quotaTransportFailure(error);
|
|
2994
|
-
}
|
|
2995
|
-
try {
|
|
2996
|
-
const response = await fetchQuota(ANTIGRAVITY_QUOTA_MODELS_URL);
|
|
2997
|
-
if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_MODELS_URL)) {
|
|
2998
|
-
return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, "redirect_blocked"));
|
|
2999
|
-
}
|
|
3000
|
-
if (!response.ok) {
|
|
3001
|
-
return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, quotaHttpFailure(response.status)));
|
|
3002
|
-
}
|
|
3003
|
-
const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response)));
|
|
3004
|
-
if (!customWindows.length) {
|
|
3005
|
-
return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, "response_unusable"));
|
|
3006
|
-
}
|
|
3007
|
-
return { kind: "available", quota: { customWindows, updatedAt: Date.now() }, source: "google-antigravity:fetchAvailableModels" };
|
|
3008
|
-
} catch (error) {
|
|
3009
|
-
// The public compatibility wrapper still rejects this exact fallback error; it never enters a DTO.
|
|
3010
|
-
return {
|
|
3011
|
-
kind: "unavailable",
|
|
3012
|
-
failure: antigravityUnavailableFailure(summaryFailure, quotaTransportFailure(error)),
|
|
3013
|
-
legacy: { kind: "throw", error },
|
|
3014
|
-
};
|
|
3015
|
-
}
|
|
3016
|
-
}
|
|
3017
|
-
|
|
3018
|
-
export async function fetchAntigravityUsageQuota(accessToken: string, projectId: string): Promise<ProviderQuota | null> {
|
|
3019
|
-
const result = await probeAntigravityUsageQuota(accessToken, projectId);
|
|
3020
|
-
if (result.kind === "available") return result.quota;
|
|
3021
|
-
if (result.legacy.kind === "throw") throw result.legacy.error;
|
|
3022
|
-
return null;
|
|
3023
|
-
}
|
|
3024
|
-
|
|
3025
|
-
async function fetchAntigravityQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
3026
|
-
const credential = getCredential("google-antigravity");
|
|
3027
|
-
if (!credential?.projectId) return null;
|
|
3028
|
-
let accessToken: string;
|
|
3029
|
-
try { accessToken = await getValidAccessToken("google-antigravity"); } catch { return null; }
|
|
3030
|
-
const result = await probeAntigravityUsageQuota(accessToken, credential.projectId);
|
|
3031
|
-
if (result.kind === "available") return report(provider, result.source, result.quota);
|
|
3032
|
-
if (result.legacy.kind === "throw") throw result.legacy.error;
|
|
3033
|
-
return null;
|
|
3034
|
-
}
|
|
3035
1
|
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
2
|
+
import { listCodexAuthAccountsSnapshot } from "../codex/auth-api";
|
|
3
|
+
import { resolveEnvValue } from "../config";
|
|
4
|
+
import { getAccountCredential, getAccountSet } from "../oauth/store";
|
|
5
|
+
import { apiKeyPoolEntryId } from "./api-keys";
|
|
6
|
+
import { captureConfigGeneration, sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
7
|
+
import { ACCOUNT_QUOTA_TTL_MS, CACHE_TTL_MS } from "./quota-wire";
|
|
8
|
+
import { replaceCachedProviderQuotas } from "./quota-routing-cache";
|
|
9
|
+
import {
|
|
10
|
+
commitKiroAccountUsageState,
|
|
11
|
+
fetchKiroUsageSnapshot,
|
|
12
|
+
type KiroUsageSnapshot,
|
|
13
|
+
kiroUsageContextForAccount,
|
|
14
|
+
} from "./kiro-usage";
|
|
15
|
+
import { mapQuotaRoster, readProviderApiKeyQuotas, type ProviderApiKeyQuota } from "./quota-key-accounts";
|
|
16
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
17
|
+
import type { ProviderQuota, QuotaFailureCode } from "./quota-types";
|
|
18
|
+
import {
|
|
19
|
+
accountReportCurrent,
|
|
20
|
+
AUTHORITATIVE_EMPTY_QUOTA,
|
|
21
|
+
bumpProviderQuotaInvalidationEpoch,
|
|
22
|
+
cacheKeyWithAggregationState,
|
|
23
|
+
getProviderQuotaReportCache,
|
|
24
|
+
hasCodexPoolProvider,
|
|
25
|
+
inflight,
|
|
26
|
+
invalidationEpoch,
|
|
27
|
+
isBuiltInChatGptForwardProvider,
|
|
28
|
+
isProviderQuotaReportCurrent,
|
|
29
|
+
LAST_GOOD_MAX_AGE_MS,
|
|
30
|
+
providerQuotaBeforePublishForTests,
|
|
31
|
+
routingEvidence,
|
|
32
|
+
setProviderQuotaReportCache,
|
|
33
|
+
TERMINAL_QUOTA_FAILURE,
|
|
34
|
+
type CodexAuthAccountsSnapshotPromise,
|
|
35
|
+
type ProviderQuotaProbeResult,
|
|
36
|
+
type ProviderQuotaReport,
|
|
37
|
+
type ProviderQuotaResponse,
|
|
38
|
+
} from "./quota/report-cache";
|
|
39
|
+
import {
|
|
40
|
+
accountCacheKey,
|
|
41
|
+
accountQuotaCache,
|
|
42
|
+
accountQuotaInflight,
|
|
43
|
+
explicitAccountEpoch,
|
|
44
|
+
explicitAccountReader,
|
|
45
|
+
explicitQuotaConfig,
|
|
46
|
+
explicitQuotaDestination,
|
|
47
|
+
explicitQuotaIdentity,
|
|
48
|
+
getTokenForAccountQuotaProbe,
|
|
49
|
+
hasPassiveAccountQuota,
|
|
50
|
+
hydrateAccountQuotaCache,
|
|
51
|
+
mayCommitAccountQuotaKey,
|
|
52
|
+
mayCommitProviderQuotaKey,
|
|
53
|
+
normalizeAnthropicQuota,
|
|
54
|
+
supportsPerAccountQuota,
|
|
55
|
+
type AccountQuotaCacheEntry,
|
|
56
|
+
type ProviderAccountQuota,
|
|
57
|
+
} from "./quota/account-cache";
|
|
58
|
+
import {
|
|
59
|
+
fetchAnthropicQuota,
|
|
60
|
+
fetchAnthropicUsageQuota,
|
|
61
|
+
fetchChatGptForwardQuota,
|
|
62
|
+
fetchCursorQuota,
|
|
63
|
+
fetchKiroQuota,
|
|
64
|
+
fetchMuseKeyQuota,
|
|
65
|
+
fetchPassiveProviderQuota,
|
|
66
|
+
fetchXaiQuota,
|
|
67
|
+
} from "./quota/vendor-probes-oauth";
|
|
68
|
+
import { fetchCommandCodeQuota, fetchKimiQuota, keyQuotaReaderForProvider } from "./quota/vendor-probes-key";
|
|
69
|
+
import { antigravityQuotaDiagnosticIdentity, fetchAntigravityQuota, probeAntigravityUsageQuota } from "./quota/antigravity";
|
|
3072
70
|
|
|
3073
|
-
export
|
|
3074
|
-
|
|
3075
|
-
|
|
71
|
+
export type { ProviderQuota, ProviderQuotaCreditsUsd, ProviderQuotaWindow } from "./quota-types";
|
|
72
|
+
export { QUOTA_RESPONSE_MAX_BYTES } from "./quota-wire";
|
|
73
|
+
export {
|
|
74
|
+
clearProviderQuotaCache,
|
|
75
|
+
publishKeyReportForTests,
|
|
76
|
+
readProviderQuotaJsonForTests,
|
|
77
|
+
setProviderQuotaBeforePublishForTests,
|
|
78
|
+
type ProviderQuotaReport,
|
|
79
|
+
type ProviderQuotaResponse,
|
|
80
|
+
} from "./quota/report-cache";
|
|
81
|
+
export {
|
|
82
|
+
clearAccountQuotaCache,
|
|
83
|
+
getCachedProviderAccountQuota,
|
|
84
|
+
hasPassiveAccountQuota,
|
|
85
|
+
parseAnthropicRateLimitHeaders,
|
|
86
|
+
providerOAuthAccountQuotaMode,
|
|
87
|
+
readPassiveProviderAccountQuotas,
|
|
88
|
+
recordAnthropicAccountQuotaFromHeaders,
|
|
89
|
+
recordPassiveAccountQuota,
|
|
90
|
+
reconcileProviderAccountQuotaRows,
|
|
91
|
+
resetProviderQuotaReconcileStateForTests,
|
|
92
|
+
setCachedProviderAccountQuotaForTests,
|
|
93
|
+
supportsPerAccountQuota,
|
|
94
|
+
sweepExpiredProviderAccountQuotaRows,
|
|
95
|
+
type ProviderAccountQuota,
|
|
96
|
+
} from "./quota/account-cache";
|
|
97
|
+
export { fetchAntigravityUsageQuota, isCanonicalAntigravityQuotaUrl, setAntigravityAccountQuotaTransportForTests } from "./quota/antigravity";
|
|
98
|
+
export { parseOllamaCloudQuota, parseZaiQuotaLimits, providerApiKeyQuotaMode } from "./quota/vendor-probes-key";
|
|
99
|
+
export { parseXaiCreditsResponse } from "./quota/vendor-probes-oauth";
|
|
3076
100
|
|
|
3077
101
|
export async function fetchProviderApiKeyQuotas(config: OcxConfig, name: string, forceRefresh = false): Promise<ProviderApiKeyQuota[]> {
|
|
3078
102
|
const provider = config.providers[name];
|
|
@@ -3220,19 +244,21 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh
|
|
|
3220
244
|
// by construction and never becomes fresher on its own. Without the exemption a single
|
|
3221
245
|
// configured passive provider makes this predicate permanently false, so every dashboard
|
|
3222
246
|
// poll re-probes every OTHER provider upstream instead of serving the 5-minute cache.
|
|
3223
|
-
const
|
|
3224
|
-
|
|
247
|
+
const currentCache = getProviderQuotaReportCache();
|
|
248
|
+
const cacheFresh = currentCache && currentCache.key === key && now - currentCache.ts < CACHE_TTL_MS
|
|
249
|
+
&& currentCache.response.reports.every(item =>
|
|
3225
250
|
(item.observed === true || now - item.updatedAt < LAST_GOOD_MAX_AGE_MS)
|
|
3226
251
|
&& isProviderQuotaReportCurrent(item));
|
|
3227
|
-
if (!forceRefresh && cacheFresh) return
|
|
252
|
+
if (!forceRefresh && cacheFresh) return currentCache!.response;
|
|
3228
253
|
const joinable = inflight.get(key);
|
|
3229
254
|
if (!forceRefresh && joinable && joinable.epoch === invalidationEpoch) return joinable.promise;
|
|
3230
255
|
// A forced probe takes commit authority: older in-flight probes must not overwrite its result.
|
|
3231
|
-
if (forceRefresh)
|
|
256
|
+
if (forceRefresh) bumpProviderQuotaInvalidationEpoch();
|
|
3232
257
|
const epoch = invalidationEpoch;
|
|
3233
258
|
|
|
3234
259
|
const promise = (async (): Promise<ProviderQuotaResponse> => {
|
|
3235
|
-
const
|
|
260
|
+
const previousCache = getProviderQuotaReportCache();
|
|
261
|
+
const previous = previousCache && previousCache.key === key ? previousCache.response.reports : [];
|
|
3236
262
|
const probeResults = await Promise.all(
|
|
3237
263
|
Object.entries(config.providers).map(([name, provider]) => (
|
|
3238
264
|
maybeFetchProviderQuota(name, provider, config, forceRefresh, prefetchedCodexSnapshot)
|
|
@@ -3296,7 +322,7 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh
|
|
|
3296
322
|
&& generationMismatchedProviders.size === 0
|
|
3297
323
|
) {
|
|
3298
324
|
const reports = response.reports.filter(item => mayCommitProviderQuotaKey(item.provider, writerGeneration));
|
|
3299
|
-
|
|
325
|
+
setProviderQuotaReportCache({ key, ts: Date.now(), response: { ...response, reports } });
|
|
3300
326
|
replaceCachedProviderQuotas(reports, routingEvidence);
|
|
3301
327
|
notifyProviderQuotaSnapshot(reports, config);
|
|
3302
328
|
}
|
|
@@ -3311,3 +337,222 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh
|
|
|
3311
337
|
if (inflight.get(key) === entry) inflight.delete(key);
|
|
3312
338
|
}
|
|
3313
339
|
}
|
|
340
|
+
|
|
341
|
+
async function readExplicitAccountQuota(provider: string, accountId: string, configured?: OcxProviderConfig): Promise<{
|
|
342
|
+
result: ProviderQuotaProbeResult;
|
|
343
|
+
identity: string | undefined;
|
|
344
|
+
isCurrent: () => boolean;
|
|
345
|
+
} | null> {
|
|
346
|
+
const target = explicitQuotaConfig(provider, configured);
|
|
347
|
+
if (!target || !explicitQuotaDestination(provider, target)) return null;
|
|
348
|
+
const config = { ...target };
|
|
349
|
+
const epoch = explicitAccountEpoch;
|
|
350
|
+
const accessToken = await getTokenForAccountQuotaProbe(provider, accountId);
|
|
351
|
+
const credential = getAccountCredential(provider, accountId);
|
|
352
|
+
if (!credential || credential.access !== accessToken) return null;
|
|
353
|
+
// Pair the post-renewal credential with the destination captured before renewal.
|
|
354
|
+
const identity = explicitQuotaIdentity(provider, accountId, config);
|
|
355
|
+
const isCurrent = () => epoch === explicitAccountEpoch
|
|
356
|
+
&& identity === explicitQuotaIdentity(provider, accountId, configured);
|
|
357
|
+
if (!isCurrent()) return null;
|
|
358
|
+
let result: ProviderQuotaProbeResult;
|
|
359
|
+
switch (provider) {
|
|
360
|
+
case "xai": result = await fetchXaiQuota(provider, { accessToken, upstreamAccountId: credential.accountId }); break;
|
|
361
|
+
case "cursor": result = await fetchCursorQuota(provider, accessToken); break;
|
|
362
|
+
case "kimi": result = await fetchKimiQuota(provider, config, accessToken); break;
|
|
363
|
+
case "command-code": result = await fetchCommandCodeQuota(provider, config, accessToken); break;
|
|
364
|
+
default: return null;
|
|
365
|
+
}
|
|
366
|
+
return { result, identity, isCurrent };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function fetchExplicitAccountQuota(provider: string, accountId: string, force: boolean, configured?: OcxProviderConfig): Promise<AccountQuotaCacheEntry> {
|
|
370
|
+
const key = accountCacheKey(provider, accountId);
|
|
371
|
+
const identity = explicitQuotaIdentity(provider, accountId, configured);
|
|
372
|
+
const previous = accountQuotaCache.get(key);
|
|
373
|
+
const cached = identity && previous?.identity === identity && previous.isCurrent?.() ? previous : undefined;
|
|
374
|
+
if (!force && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS
|
|
375
|
+
&& (!cached.quota || Date.now() - cached.quota.updatedAt < LAST_GOOD_MAX_AGE_MS)) return cached;
|
|
376
|
+
const flightKey = `${key}\u0000${identity ?? "missing"}`;
|
|
377
|
+
const running = accountQuotaInflight.get(flightKey);
|
|
378
|
+
if (running) return running;
|
|
379
|
+
const epoch = explicitAccountEpoch;
|
|
380
|
+
const lastGood = cached?.quota && Date.now() - cached.quota.updatedAt < LAST_GOOD_MAX_AGE_MS ? cached.quota : null;
|
|
381
|
+
const flight = (async (): Promise<AccountQuotaCacheEntry> => {
|
|
382
|
+
let read: Awaited<ReturnType<typeof readExplicitAccountQuota>> = null;
|
|
383
|
+
try { read = await readExplicitAccountQuota(provider, accountId, configured); } catch { /* unavailable */ }
|
|
384
|
+
const isCurrent = read?.isCurrent ?? (() => epoch === explicitAccountEpoch && !!identity
|
|
385
|
+
&& identity === explicitQuotaIdentity(provider, accountId, configured));
|
|
386
|
+
const result = read?.result;
|
|
387
|
+
const current = epoch === explicitAccountEpoch && isCurrent();
|
|
388
|
+
const quota = current && result && typeof result !== "symbol" ? result.quota : null;
|
|
389
|
+
const empty = result === AUTHORITATIVE_EMPTY_QUOTA;
|
|
390
|
+
const entry: AccountQuotaCacheEntry = {
|
|
391
|
+
ts: Date.now(),
|
|
392
|
+
quota: quota ?? (current && result !== TERMINAL_QUOTA_FAILURE && !empty
|
|
393
|
+
&& lastGood && Date.now() - lastGood.updatedAt < LAST_GOOD_MAX_AGE_MS ? lastGood : null),
|
|
394
|
+
...(!current || (!quota && !empty) ? { unavailable: true as const } : {}),
|
|
395
|
+
identity: read?.identity ?? identity,
|
|
396
|
+
isCurrent: () => epoch === explicitAccountEpoch && isCurrent(),
|
|
397
|
+
};
|
|
398
|
+
if (entry.isCurrent?.()) accountQuotaCache.set(key, entry);
|
|
399
|
+
return entry;
|
|
400
|
+
})().finally(() => { if (accountQuotaInflight.get(flightKey) === flight) accountQuotaInflight.delete(flightKey); });
|
|
401
|
+
accountQuotaInflight.set(flightKey, flight);
|
|
402
|
+
return flight;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function fetchExplicitCurrentQuota(provider: string, config: OcxProviderConfig, liveConfig: OcxConfig): Promise<ProviderQuotaProbeResult> {
|
|
406
|
+
const id = getAccountSet(provider)?.activeAccountId;
|
|
407
|
+
if (!id) return null;
|
|
408
|
+
const read = await readExplicitAccountQuota(provider, id, config);
|
|
409
|
+
if (!read) return null;
|
|
410
|
+
const isCurrent = () => liveConfig.providers[provider] === config
|
|
411
|
+
&& read.isCurrent() && getAccountSet(provider)?.activeAccountId === id;
|
|
412
|
+
if (!isCurrent()) return TERMINAL_QUOTA_FAILURE;
|
|
413
|
+
if (read.result && typeof read.result !== "symbol") accountReportCurrent.set(read.result, isCurrent);
|
|
414
|
+
return read.result;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
async function fetchAccountQuota(
|
|
419
|
+
provider: string,
|
|
420
|
+
accountId: string,
|
|
421
|
+
forceRefresh: boolean,
|
|
422
|
+
providerConfig?: OcxProviderConfig,
|
|
423
|
+
): Promise<AccountQuotaCacheEntry> {
|
|
424
|
+
if (!supportsPerAccountQuota(provider)) return { ts: Date.now(), quota: null, unavailable: true };
|
|
425
|
+
if (explicitAccountReader(provider)) return fetchExplicitAccountQuota(provider, accountId, forceRefresh, providerConfig);
|
|
426
|
+
if (provider === "anthropic") hydrateAccountQuotaCache();
|
|
427
|
+
const key = accountCacheKey(provider, accountId);
|
|
428
|
+
const writerGeneration = captureConfigGeneration();
|
|
429
|
+
const cached = accountQuotaCache.get(key);
|
|
430
|
+
if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) {
|
|
431
|
+
if (provider === "google-antigravity" && cached.quotaFailure && cached.quotaFailureIsCurrent?.() !== true) return { ...cached, quotaFailure: undefined };
|
|
432
|
+
return provider === "anthropic" ? { ...cached, quota: normalizeAnthropicQuota(cached.quota, Date.now()) } : cached;
|
|
433
|
+
}
|
|
434
|
+
const joinable = accountQuotaInflight.get(key);
|
|
435
|
+
if (joinable) return joinable;
|
|
436
|
+
|
|
437
|
+
const epoch = explicitAccountEpoch;
|
|
438
|
+
const probe = (async (): Promise<AccountQuotaCacheEntry> => {
|
|
439
|
+
let diagnosticIdentity: string | undefined;
|
|
440
|
+
let quotaFailure: QuotaFailureCode | undefined;
|
|
441
|
+
const quotaFailureIsCurrent = () => {
|
|
442
|
+
try { return epoch === explicitAccountEpoch && diagnosticIdentity !== undefined && diagnosticIdentity === antigravityQuotaDiagnosticIdentity(accountId); }
|
|
443
|
+
catch { return false; }
|
|
444
|
+
};
|
|
445
|
+
const diagnosticFields = () => quotaFailure && quotaFailureIsCurrent() ? { quotaFailure, quotaFailureIsCurrent } : {};
|
|
446
|
+
try {
|
|
447
|
+
if (provider === "google-antigravity") diagnosticIdentity = antigravityQuotaDiagnosticIdentity(accountId);
|
|
448
|
+
let quota: ProviderQuota | null;
|
|
449
|
+
let kiroSnapshot: KiroUsageSnapshot | null = null;
|
|
450
|
+
if (provider === "kiro") {
|
|
451
|
+
// Kiro resolves the bearer and its routing metadata from ONE account-scoped
|
|
452
|
+
// snapshot. It deliberately does not use getTokenForAccountQuotaProbe: that
|
|
453
|
+
// helper refuses to refresh a background `local-cli` slot because Anthropic's
|
|
454
|
+
// lock can adopt a mismatched Claude CLI identity, but Kiro marks every
|
|
455
|
+
// CLI-imported credential `local-cli`, so the same rule would blank the quota of
|
|
456
|
+
// every inactive pool account the moment its token expired.
|
|
457
|
+
kiroSnapshot = await fetchKiroUsageSnapshot(await kiroUsageContextForAccount(accountId));
|
|
458
|
+
quota = kiroSnapshot?.quota ?? null;
|
|
459
|
+
} else {
|
|
460
|
+
const token = await getTokenForAccountQuotaProbe(provider, accountId);
|
|
461
|
+
if (provider === "google-antigravity") {
|
|
462
|
+
// Per-account Gem/Cla windows (#1082). The project id is part of the stored
|
|
463
|
+
// credential; without it the probe cannot be made, and that is "unavailable",
|
|
464
|
+
// never 0%.
|
|
465
|
+
const credential = getAccountCredential(provider, accountId);
|
|
466
|
+
diagnosticIdentity = credential?.access === token ? antigravityQuotaDiagnosticIdentity(accountId, credential) : undefined;
|
|
467
|
+
if (!diagnosticIdentity || !credential?.projectId) throw new Error("antigravity account unavailable");
|
|
468
|
+
const result = await probeAntigravityUsageQuota(token, credential.projectId);
|
|
469
|
+
quota = result.kind === "available" ? result.quota : null;
|
|
470
|
+
if (result.kind === "unavailable") quotaFailure = result.failure;
|
|
471
|
+
} else if (provider === "anthropic") {
|
|
472
|
+
quota = await fetchAnthropicUsageQuota(token);
|
|
473
|
+
} else {
|
|
474
|
+
return { ts: Date.now(), quota: null, unavailable: true };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (!quota) {
|
|
478
|
+
// Preserve last-good bars and mark unavailable; advance TTL so failures
|
|
479
|
+
// negative-cache instead of re-probing on every GUI poll.
|
|
480
|
+
const entry: AccountQuotaCacheEntry = {
|
|
481
|
+
ts: Date.now(),
|
|
482
|
+
// Settle once for all joiners against observations committed during the probe.
|
|
483
|
+
quota: provider === "anthropic"
|
|
484
|
+
? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null,
|
|
485
|
+
unavailable: true,
|
|
486
|
+
...diagnosticFields(),
|
|
487
|
+
};
|
|
488
|
+
if (mayCommitAccountQuotaKey(key, writerGeneration)) {
|
|
489
|
+
accountQuotaCache.set(key, entry);
|
|
490
|
+
if (provider === "kiro") commitKiroAccountUsageState(key, null);
|
|
491
|
+
sweepExpiredOnWrite(entry.ts);
|
|
492
|
+
}
|
|
493
|
+
return entry;
|
|
494
|
+
}
|
|
495
|
+
const entry: AccountQuotaCacheEntry = {
|
|
496
|
+
ts: Date.now(), quota: provider === "anthropic" ? normalizeAnthropicQuota(quota, Date.now()) : quota,
|
|
497
|
+
};
|
|
498
|
+
if (mayCommitAccountQuotaKey(key, writerGeneration)) {
|
|
499
|
+
accountQuotaCache.set(key, entry);
|
|
500
|
+
// Exhaustion state rides the SAME commit guard as the quota row: a probe from a
|
|
501
|
+
// superseded config generation must not publish either half.
|
|
502
|
+
if (provider === "kiro") commitKiroAccountUsageState(key, kiroSnapshot);
|
|
503
|
+
sweepExpiredOnWrite(entry.ts);
|
|
504
|
+
}
|
|
505
|
+
return entry;
|
|
506
|
+
} catch {
|
|
507
|
+
if (provider === "google-antigravity") quotaFailure = "account_unavailable";
|
|
508
|
+
const entry: AccountQuotaCacheEntry = {
|
|
509
|
+
ts: Date.now(),
|
|
510
|
+
quota: provider === "anthropic"
|
|
511
|
+
? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null,
|
|
512
|
+
unavailable: true,
|
|
513
|
+
...diagnosticFields(),
|
|
514
|
+
};
|
|
515
|
+
if (mayCommitAccountQuotaKey(key, writerGeneration)) {
|
|
516
|
+
accountQuotaCache.set(key, entry);
|
|
517
|
+
sweepExpiredOnWrite(entry.ts);
|
|
518
|
+
}
|
|
519
|
+
return entry;
|
|
520
|
+
}
|
|
521
|
+
})().finally(() => {
|
|
522
|
+
if (accountQuotaInflight.get(key) === probe) accountQuotaInflight.delete(key);
|
|
523
|
+
});
|
|
524
|
+
accountQuotaInflight.set(key, probe);
|
|
525
|
+
return probe;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Per-account quota rows for a provider's logged-in accounts. Probes run in parallel; a
|
|
530
|
+
* single failing account never blocks the others.
|
|
531
|
+
*/
|
|
532
|
+
export async function fetchProviderAccountQuotas(
|
|
533
|
+
provider: string,
|
|
534
|
+
forceRefresh = false,
|
|
535
|
+
providerConfig?: OcxProviderConfig,
|
|
536
|
+
): Promise<ProviderAccountQuota[]> {
|
|
537
|
+
if (!supportsPerAccountQuota(provider)) return [];
|
|
538
|
+
const set = getAccountSet(provider);
|
|
539
|
+
if (!set) return [];
|
|
540
|
+
return mapQuotaRoster(set.accounts, async account => {
|
|
541
|
+
const entry = await fetchAccountQuota(provider, account.id, forceRefresh, providerConfig);
|
|
542
|
+
const result: ProviderAccountQuota = {
|
|
543
|
+
accountId: account.id,
|
|
544
|
+
quota: provider === "anthropic" ? normalizeAnthropicQuota(entry.quota, Date.now()) : entry.quota,
|
|
545
|
+
...(entry.unavailable ? { unavailable: true as const } : {}),
|
|
546
|
+
...(entry.unavailable && entry.quotaFailure && entry.quotaFailureIsCurrent?.() === true ? { quotaFailure: entry.quotaFailure } : {}),
|
|
547
|
+
};
|
|
548
|
+
if (entry.quotaFailureIsCurrent) Object.defineProperty(result, "quotaFailureIsCurrent", { value: entry.quotaFailureIsCurrent });
|
|
549
|
+
if (!explicitAccountReader(provider)) return result;
|
|
550
|
+
const identity = entry.identity;
|
|
551
|
+
Object.defineProperty(result, "isCurrent", { value: () => {
|
|
552
|
+
if (entry.isCurrent) return entry.isCurrent();
|
|
553
|
+
const credential = getAccountCredential(provider, account.id);
|
|
554
|
+
return !!credential && (!identity || explicitQuotaIdentity(provider, account.id, providerConfig) === identity);
|
|
555
|
+
} });
|
|
556
|
+
return result;
|
|
557
|
+
});
|
|
558
|
+
}
|