@bitkyc08/opencodex 2.41.0 → 2.43.0-preview.20260906
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +2 -2
- package/README.md +31 -0
- package/bin/ocx.mjs +10 -1
- package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
- package/gui/dist/assets/index-VGeQEZ_v.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-codec.ts +304 -0
- package/src/adapters/anthropic-image-normalize.ts +8 -298
- package/src/adapters/anthropic.ts +30 -7
- package/src/adapters/command-code.ts +7 -5
- package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
- package/src/adapters/cursor/images.ts +36 -6
- package/src/adapters/cursor/live-transport.ts +7 -2
- package/src/adapters/cursor/native-exec-desktop.ts +2 -15
- package/src/adapters/cursor/protobuf-request.ts +54 -24
- package/src/adapters/cursor/tool-definitions.ts +5 -670
- package/src/adapters/cursor/tool-guidance.ts +236 -0
- package/src/adapters/cursor/tool-naming.ts +252 -0
- package/src/adapters/cursor/tool-schemas.ts +195 -0
- package/src/adapters/cursor/types.ts +6 -3
- package/src/adapters/exec-tool-result-normalize.ts +1 -1
- package/src/adapters/google-errors.ts +9 -1
- package/src/adapters/google.ts +1 -0
- package/src/adapters/identity.ts +8 -2
- package/src/adapters/kiro-calibration.ts +181 -0
- package/src/adapters/kiro.ts +135 -3
- package/src/adapters/openai-responses.ts +259 -29
- package/src/adapters/responses-code-mode.ts +59 -0
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/adapters/xai-schema-analysis.ts +86 -0
- package/src/adapters/xai-tool-schema.ts +2 -87
- package/src/adapters/xai-web-search.ts +1 -1
- package/src/bridge.ts +47 -13
- package/src/chat/inbound.ts +11 -3
- package/src/claude/inbound-content-options.ts +60 -0
- package/src/claude/inbound-model-options.ts +142 -0
- package/src/claude/inbound-records.ts +7 -0
- package/src/claude/inbound.ts +10 -202
- package/src/claude/model-info.ts +45 -0
- package/src/cli/account-auth.ts +49 -9
- package/src/cli/account-extended.ts +7 -1
- package/src/cli/capabilities.ts +15 -4
- package/src/cli/claude.ts +232 -39
- package/src/cli/config-command.ts +9 -1
- package/src/cli/dispatch.ts +5 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/effort.ts +372 -0
- package/src/cli/export-command.ts +3 -9
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +13 -0
- package/src/cli/init.ts +4 -0
- package/src/cli/model-selection-guidance.ts +30 -0
- package/src/cli/models-runtime.ts +3 -2
- package/src/cli/models.ts +8 -3
- package/src/cli/observe.ts +3 -1
- package/src/cli/opencode.ts +4 -1
- package/src/cli/provider-runtime.ts +65 -0
- package/src/cli/provider.ts +8 -0
- package/src/cli/registry.ts +16 -2
- package/src/cli/runtime-api.ts +3 -1
- package/src/cli/star-prompt.ts +22 -6
- package/src/cli/status-probes.ts +168 -0
- package/src/cli/status.ts +5 -168
- package/src/clients/config-export/constants.ts +69 -0
- package/src/clients/config-export/contracts.ts +154 -0
- package/src/clients/config-export/dsh.ts +132 -0
- package/src/clients/config-export/fast-models.ts +29 -0
- package/src/clients/config-export/mcode.ts +83 -0
- package/src/clients/config-export/model-metadata.ts +108 -0
- package/src/clients/config-export/omp.ts +104 -0
- package/src/clients/config-export/zcode.ts +92 -0
- package/src/clients/config-export.ts +18 -710
- package/src/codex/account-lifecycle.ts +20 -3
- package/src/codex/account-usability.ts +2 -0
- package/src/codex/auth-api.ts +272 -32
- package/src/codex/auth-context.ts +328 -24
- package/src/codex/catalog/effort.ts +44 -5
- package/src/codex/catalog/metadata.ts +149 -14
- package/src/codex/catalog/native-models.ts +116 -4
- package/src/codex/catalog/parsing.ts +122 -8
- package/src/codex/catalog/provider-fetch.ts +154 -23
- package/src/codex/catalog/reserve.ts +52 -0
- package/src/codex/catalog/sync.ts +89 -16
- package/src/codex/catalog.ts +1 -1
- package/src/codex/convergence-types.ts +1 -0
- package/src/codex/convergence.ts +2 -0
- package/src/codex/data/upstream-models.json +169 -0
- package/src/codex/forward-transport-headers.ts +25 -0
- package/src/codex/inject.ts +99 -34
- package/src/codex/injected-marker.ts +30 -4
- package/src/codex/journal.ts +14 -0
- package/src/codex/legacy-config-keys.ts +68 -0
- package/src/codex/log-guard/inspect-schema.ts +137 -0
- package/src/codex/log-guard/inspect.ts +2 -134
- package/src/codex/loopback-target.ts +54 -0
- package/src/codex/main-account-cache.ts +63 -1
- package/src/codex/main-account-hard-lock.ts +52 -0
- package/src/codex/main-account.ts +3 -1
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/model-entitlements.ts +54 -4
- package/src/codex/project-config-warnings.ts +92 -2
- package/src/codex/prompt-layers/encoding.ts +80 -0
- package/src/codex/prompt-layers/paths.ts +54 -0
- package/src/codex/prompt-layers/revision.ts +55 -0
- package/src/codex/prompt-layers/toml-edit.ts +163 -0
- package/src/codex/prompt-layers/toml-read.ts +181 -0
- package/src/codex/prompt-layers.ts +14 -520
- package/src/codex/quota-auto-refresh-state.ts +16 -0
- package/src/codex/quota-auto-refresh.ts +219 -0
- package/src/codex/quota-types.ts +51 -0
- package/src/codex/quota.ts +252 -93
- package/src/codex/reserve-availability.ts +177 -0
- package/src/codex/routing.ts +28 -9
- package/src/codex/shim.ts +53 -11
- package/src/codex/subagent-model-fallback.ts +23 -3
- package/src/combos/failover.ts +125 -7
- package/src/combos/identifiers.ts +89 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/resolve.ts +80 -9
- package/src/combos/types.ts +20 -93
- package/src/config/subagent-models.ts +24 -0
- package/src/config.ts +156 -13
- package/src/generated/compatibility-version.json +474 -178
- package/src/generated/model-metadata.ts +1 -1
- package/src/integrations/journal.ts +65 -4
- package/src/integrations/store.ts +5 -0
- package/src/lab/events/limits.ts +4 -0
- package/src/lib/destination-policy.ts +31 -2
- package/src/lib/errors.ts +39 -0
- package/src/lib/provider-outbound.ts +69 -3
- package/src/lib/proxy-env.ts +22 -0
- package/src/lib/redact-folding.ts +176 -0
- package/src/lib/redact.ts +2 -175
- package/src/lib/state-store-sweeper.ts +20 -6
- package/src/lib/token-estimate.ts +94 -27
- package/src/lib/windows-user-principal.ts +53 -5
- package/src/oauth/account-quota-rank.ts +40 -1
- package/src/oauth/anthropic-routing.ts +99 -3
- package/src/oauth/chatgpt-device.ts +187 -0
- package/src/oauth/chatgpt.ts +31 -4
- package/src/oauth/generic-account-failover.ts +36 -13
- package/src/oauth/index.ts +140 -29
- package/src/oauth/log.ts +3 -0
- package/src/oauth/login-cli.ts +5 -0
- package/src/oauth/meta-muse.ts +117 -15
- package/src/oauth/pool-settings-capability.ts +15 -4
- package/src/providers/api-keys.ts +8 -10
- package/src/providers/default-aliases.ts +39 -0
- package/src/providers/derive.ts +10 -2
- package/src/providers/fastwire.ts +36 -7
- package/src/providers/initial-model-selection-runtime.ts +90 -0
- package/src/providers/initial-model-selection.ts +120 -0
- package/src/providers/key-failover.ts +134 -54
- package/src/providers/key-store.ts +11 -1
- package/src/providers/label.ts +1 -1
- package/src/providers/model-discovery.ts +76 -0
- package/src/providers/model-rename-startup.ts +72 -8
- package/src/providers/muse-subscription-usage.ts +95 -0
- package/src/providers/openai-sidecar.ts +17 -5
- package/src/providers/openai-tiers-destination.ts +102 -0
- package/src/providers/openai-tiers.ts +2 -99
- package/src/providers/opencode-go-transport.ts +41 -0
- package/src/providers/quota-key-accounts.ts +141 -0
- package/src/providers/quota-types.ts +9 -0
- package/src/providers/quota.ts +625 -98
- package/src/providers/registry.ts +60 -17
- package/src/providers/xai-responses-opt-in.ts +31 -5
- package/src/quota/reset-activation.ts +81 -0
- package/src/quota/reset-detector.ts +305 -0
- package/src/quota/reset-notify-config.ts +162 -0
- package/src/quota/reset-observer.ts +125 -0
- package/src/quota/reset-poller.ts +160 -0
- package/src/quota/reset-seen-store.ts +385 -0
- package/src/quota/reset-sinks.ts +199 -0
- package/src/quota/window-mapping.ts +106 -0
- package/src/responses/apply-patch-envelope.ts +46 -0
- package/src/responses/code-mode-helper-compat.ts +39 -1
- package/src/responses/custom-tool-compat.ts +10 -4
- package/src/responses/hosted-tool-policy.ts +12 -4
- package/src/responses/parser-content.ts +133 -0
- package/src/responses/parser-text-format.ts +24 -0
- package/src/responses/parser-tools.ts +188 -0
- package/src/responses/parser.ts +3 -326
- package/src/responses/state.ts +124 -28
- package/src/router.ts +48 -13
- package/src/routing/analytics.ts +1 -0
- package/src/routing/capability.ts +17 -4
- package/src/server/auth-cors.ts +7 -1
- package/src/server/background-lifecycle.ts +23 -1
- package/src/server/chat-completions.ts +25 -3
- package/src/server/claude-messages.ts +62 -5
- package/src/server/effort-row.ts +1 -1
- package/src/server/fast-row.ts +295 -0
- package/src/server/gui-static.ts +30 -4
- package/src/server/index.ts +122 -28
- package/src/server/live.ts +18 -4
- package/src/server/management/agent-settings-routes.ts +2 -2
- package/src/server/management/combo-routes.ts +37 -9
- package/src/server/management/config-routes.ts +93 -2
- package/src/server/management/integration-routes.ts +108 -0
- package/src/server/management/model-routes.ts +13 -3
- package/src/server/management/model-rows.ts +20 -1
- package/src/server/management/native-integration-routes.ts +4 -1
- package/src/server/management/oauth-account-routes.ts +45 -10
- package/src/server/management/provider-routes.ts +34 -3
- package/src/server/management/quota-reset-routes.ts +57 -0
- package/src/server/management/route-registry.ts +7 -4
- package/src/server/management/shared.ts +19 -5
- package/src/server/management/system-routes.ts +3 -2
- package/src/server/management-api.ts +14 -2
- package/src/server/ports.ts +12 -2
- package/src/server/relay-eager.ts +38 -23
- package/src/server/relay.ts +4 -0
- package/src/server/request-log.ts +6 -0
- package/src/server/responses/codex-ws-correlation.ts +65 -0
- package/src/server/responses/codex-ws-exchange.ts +261 -0
- package/src/server/responses/codex-ws-metadata.ts +134 -0
- package/src/server/responses/codex-ws-pool.ts +162 -0
- package/src/server/responses/codex-ws-request.ts +87 -0
- package/src/server/responses/codex-ws-session.ts +93 -0
- package/src/server/responses/codex-ws-wire.ts +144 -0
- package/src/server/responses/collaboration.ts +41 -1
- package/src/server/responses/compact.ts +105 -12
- package/src/server/responses/core.ts +510 -57
- package/src/server/responses/empty-completion-guard.ts +4 -0
- package/src/server/responses/fetch-helpers.ts +10 -3
- package/src/server/responses/input-admission.ts +16 -9
- package/src/server/responses/responses-field-backfill.ts +1 -1
- package/src/server/responses/ws-upstream.ts +34 -318
- package/src/server/responses-custom-tool-repair.ts +20 -4
- package/src/server/responses-undeclared-tool-guard.ts +100 -8
- package/src/server/safe-response-headers.ts +23 -0
- package/src/server/search.ts +9 -0
- package/src/server/subagent-models-startup.ts +27 -0
- package/src/server/system-env-shell.ts +238 -0
- package/src/server/system-env.ts +7 -234
- package/src/server/ws-bridge.ts +3 -25
- package/src/server/xai-responses-startup.ts +21 -0
- package/src/service-manager-probe.ts +1 -1
- package/src/service.ts +55 -16
- package/src/types/config.ts +108 -12
- package/src/types/provider.ts +36 -7
- package/src/types/request.ts +8 -0
- package/src/types/tools.ts +26 -1
- package/src/types.ts +2 -0
- package/src/update/notify.ts +8 -2
- package/src/usage/cost.ts +38 -28
- package/src/usage/expected-prices.ts +34 -15
- package/src/usage/log.ts +2 -0
- package/src/usage/model-identity.ts +26 -0
- package/src/usage/summary.ts +15 -1
- package/src/vision/describe.ts +6 -0
- package/src/vision/image-rewrite.ts +108 -0
- package/src/vision/index.ts +19 -306
- package/src/vision/plan.ts +205 -0
- package/src/web-search/executor.ts +6 -0
- package/src/web-search/index.ts +8 -1
- package/gui/dist/assets/index-B2YjLA-i.css +0 -1
- package/gui/dist/assets/index-aPup8CKb.js +0 -112
package/src/providers/quota.ts
CHANGED
|
@@ -9,10 +9,12 @@ import type { StoredAccountQuota } from "../codex/quota";
|
|
|
9
9
|
import { isMainAccountIdentityGenerationLive } from "../codex/main-account-cache";
|
|
10
10
|
import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
|
|
11
11
|
import { codexPlanKey } from "../codex/plan";
|
|
12
|
+
import { resolveEnvValue } from "../config";
|
|
12
13
|
import { resolveProviderApiKey } from "./key-store";
|
|
13
14
|
import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth";
|
|
14
15
|
import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store";
|
|
15
16
|
import { antigravityUserAgent } from "../adapters/client-fingerprint";
|
|
17
|
+
import { isCanonicalOllamaCloudUrl } from "../adapters/ollama-native-url";
|
|
16
18
|
import { providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../lib/provider-outbound";
|
|
17
19
|
import { apiKeyPoolEntryId } from "./api-keys";
|
|
18
20
|
import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport";
|
|
@@ -46,6 +48,7 @@ import {
|
|
|
46
48
|
type CodexCapacityQuota,
|
|
47
49
|
} from "./codex-capacity";
|
|
48
50
|
import type {
|
|
51
|
+
AccountQuotaMode,
|
|
49
52
|
ProviderQuota,
|
|
50
53
|
ProviderQuotaCreditsUsd,
|
|
51
54
|
ProviderQuotaWindow,
|
|
@@ -63,6 +66,7 @@ import {
|
|
|
63
66
|
readPersistedAccountQuotas,
|
|
64
67
|
schedulePersistAccountQuotas,
|
|
65
68
|
} from "./account-quota-disk";
|
|
69
|
+
import { clearProviderApiKeyQuotaCache, mapQuotaRoster, readProviderApiKeyQuotas, type ProviderApiKeyQuota } from "./quota-key-accounts";
|
|
66
70
|
|
|
67
71
|
export type { ProviderQuota, ProviderQuotaCreditsUsd, ProviderQuotaWindow } from "./quota-types";
|
|
68
72
|
|
|
@@ -83,6 +87,8 @@ const OPENCODE_GO_USAGE_URL = `${OPENCODE_GO_BASE_URL}/usage`;
|
|
|
83
87
|
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
|
84
88
|
const DEEPSEEK_BASE_URL = "https://api.deepseek.com";
|
|
85
89
|
const CLINE_BASE_URL = "https://api.cline.bot";
|
|
90
|
+
const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
|
|
91
|
+
const OLLAMA_CLOUD_USAGE_URL = `${OLLAMA_CLOUD_BASE_URL}/api/usage`;
|
|
86
92
|
const ZAI_BASE_URL = "https://api.z.ai";
|
|
87
93
|
const ZAI_CN_BASE_URL = "https://open.bigmodel.cn";
|
|
88
94
|
const MINIMAX_REMAINS_URL = "https://www.minimax.io/v1/token_plan/remains";
|
|
@@ -96,6 +102,7 @@ const XAI_CREDITS_URL = `${XAI_BILLING_URL}?format=credits`;
|
|
|
96
102
|
/** Keep a failed probe's previous row at most this long before dropping it. */
|
|
97
103
|
const LAST_GOOD_MAX_AGE_MS = CODEX_CAPACITY_MAX_QUOTA_AGE_MS;
|
|
98
104
|
const nativeMainReportGenerations = new WeakMap<ProviderQuotaReport, number>();
|
|
105
|
+
const accountReportCurrent = new WeakMap<ProviderQuotaReport, () => boolean>();
|
|
99
106
|
let providerQuotaBeforePublishForTests: (() => void | Promise<void>) | null = null;
|
|
100
107
|
|
|
101
108
|
/** Test-only seam for identity/config invalidation after probes but before publication. */
|
|
@@ -130,6 +137,18 @@ export interface ProviderQuotaReport {
|
|
|
130
137
|
quota: ProviderQuota;
|
|
131
138
|
updatedAt: number;
|
|
132
139
|
reverseEngineered?: boolean;
|
|
140
|
+
/**
|
|
141
|
+
* The row was OBSERVED in-band on a streaming turn rather than probed.
|
|
142
|
+
*
|
|
143
|
+
* Age means something different for these. A probed provider re-reads on its own TTL,
|
|
144
|
+
* so a row older than the last-good bound means the probe is failing and showing it
|
|
145
|
+
* would misrepresent a live number. A passive provider publishes no endpoint at all
|
|
146
|
+
* (`hasPassiveAccountQuota`), so its last observation is not a stale reading of
|
|
147
|
+
* something fresher — it is the only measurement that exists, and dropping it leaves
|
|
148
|
+
* the operator with nothing. Consumers that enforce a freshness bound must exempt
|
|
149
|
+
* these and state the observation age instead.
|
|
150
|
+
*/
|
|
151
|
+
observed?: boolean;
|
|
133
152
|
aggregation?: CodexCapacityAggregation;
|
|
134
153
|
}
|
|
135
154
|
|
|
@@ -147,6 +166,7 @@ let invalidationEpoch = 0;
|
|
|
147
166
|
export function clearProviderQuotaCache(): void {
|
|
148
167
|
cache = null;
|
|
149
168
|
clearCachedProviderQuotas();
|
|
169
|
+
clearProviderApiKeyQuotaCache();
|
|
150
170
|
invalidationEpoch += 1;
|
|
151
171
|
}
|
|
152
172
|
|
|
@@ -316,6 +336,15 @@ function isCanonicalClineBaseUrl(baseUrl: string): boolean {
|
|
|
316
336
|
return normalized === CLINE_BASE_URL || normalized === `${CLINE_BASE_URL}/api/v1`;
|
|
317
337
|
}
|
|
318
338
|
|
|
339
|
+
function isCanonicalOllamaCloudBaseUrl(baseUrl?: string): boolean {
|
|
340
|
+
if (!baseUrl) return false;
|
|
341
|
+
try {
|
|
342
|
+
return isCanonicalOllamaCloudUrl(baseUrl);
|
|
343
|
+
} catch {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
319
348
|
function isCanonicalZaiBaseUrl(baseUrl: string): boolean {
|
|
320
349
|
const normalized = normalizedBaseUrl(baseUrl);
|
|
321
350
|
return normalized === ZAI_BASE_URL
|
|
@@ -647,6 +676,78 @@ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Pro
|
|
|
647
676
|
return windows > 0 ? report(provider, "cline:plan-usage-limits", quota) : null;
|
|
648
677
|
}
|
|
649
678
|
|
|
679
|
+
/**
|
|
680
|
+
* Ollama Cloud `GET https://ollama.com/api/usage` — returns account usage.
|
|
681
|
+
* Legacy plans report rolling 5-hour `limits.session.usage` and 7-day
|
|
682
|
+
* `limits.weekly.usage`. Migrated monthly-credit plans report
|
|
683
|
+
* `limits.monthly.usage`. `usage` values are normalized fractions (0..1).
|
|
684
|
+
*/
|
|
685
|
+
function parseOllamaPercent(usageValue: unknown): number | undefined {
|
|
686
|
+
const usage = toFiniteNumber(usageValue);
|
|
687
|
+
if (usage === undefined || usage < 0) return undefined;
|
|
688
|
+
const percent = Math.round(usage * 10000) / 100;
|
|
689
|
+
return normalizePercent(percent);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export function parseOllamaCloudQuota(body: Record<string, unknown> | null): ProviderQuota | null {
|
|
693
|
+
if (!body) return null;
|
|
694
|
+
const limits = asRecord(body.limits);
|
|
695
|
+
if (!limits) return null;
|
|
696
|
+
|
|
697
|
+
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
698
|
+
let windows = 0;
|
|
699
|
+
|
|
700
|
+
const session = asRecord(limits.session);
|
|
701
|
+
if (session) {
|
|
702
|
+
const percent = parseOllamaPercent(session.usage);
|
|
703
|
+
if (percent !== undefined) {
|
|
704
|
+
quota.fiveHourPercent = percent;
|
|
705
|
+
windows += 1;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const weekly = asRecord(limits.weekly);
|
|
710
|
+
if (weekly) {
|
|
711
|
+
const percent = parseOllamaPercent(weekly.usage);
|
|
712
|
+
if (percent !== undefined) {
|
|
713
|
+
quota.weeklyPercent = percent;
|
|
714
|
+
windows += 1;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const monthly = asRecord(limits.monthly);
|
|
719
|
+
if (monthly) {
|
|
720
|
+
const percent = parseOllamaPercent(monthly.usage);
|
|
721
|
+
if (percent !== undefined) {
|
|
722
|
+
quota.monthlyPercent = percent;
|
|
723
|
+
windows += 1;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return windows > 0 ? quota : null;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function fetchOllamaCloudQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
731
|
+
const effectiveBaseUrl = config.baseUrl ?? getProviderRegistryEntry(provider)?.baseUrl ?? "";
|
|
732
|
+
if (!isCanonicalOllamaCloudBaseUrl(effectiveBaseUrl)) return null;
|
|
733
|
+
const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
|
|
734
|
+
if (!apiKey) return null;
|
|
735
|
+
const response = await fetch(OLLAMA_CLOUD_USAGE_URL, {
|
|
736
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
737
|
+
redirect: "error",
|
|
738
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
739
|
+
});
|
|
740
|
+
if (!response.ok) {
|
|
741
|
+
if (response.status === 404) return null;
|
|
742
|
+
return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
|
|
743
|
+
? TERMINAL_QUOTA_FAILURE
|
|
744
|
+
: null;
|
|
745
|
+
}
|
|
746
|
+
const body = asRecord(await readQuotaJson(response));
|
|
747
|
+
const quota = parseOllamaCloudQuota(body);
|
|
748
|
+
return quota ? report(provider, "ollama-cloud:usage", quota) : null;
|
|
749
|
+
}
|
|
750
|
+
|
|
650
751
|
/**
|
|
651
752
|
* Z.AI GLM Coding Plan `GET /api/monitor/usage/quota/limit` — the coding-plan
|
|
652
753
|
* limits arrive as a `limits` array of `TOKENS_LIMIT` (newer plans call the
|
|
@@ -1085,7 +1186,8 @@ function tagNativeMainReport(
|
|
|
1085
1186
|
|
|
1086
1187
|
function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolean {
|
|
1087
1188
|
const generation = nativeMainReportGenerations.get(value);
|
|
1088
|
-
return generation === undefined || isMainAccountIdentityGenerationLive(generation)
|
|
1189
|
+
return (generation === undefined || isMainAccountIdentityGenerationLive(generation))
|
|
1190
|
+
&& (accountReportCurrent.get(value)?.() ?? true);
|
|
1089
1191
|
}
|
|
1090
1192
|
|
|
1091
1193
|
async function fetchChatGptForwardQuota(
|
|
@@ -1206,6 +1308,7 @@ export function parseXaiCreditsResponse(value: unknown): { percent: number; rese
|
|
|
1206
1308
|
async function fetchXaiWeeklyCredits(accessToken: string, userId: string): Promise<ProviderQuota | null> {
|
|
1207
1309
|
try {
|
|
1208
1310
|
const response = await fetch(XAI_CREDITS_URL, {
|
|
1311
|
+
redirect: "error",
|
|
1209
1312
|
headers: {
|
|
1210
1313
|
Accept: "application/json",
|
|
1211
1314
|
Authorization: `Bearer ${accessToken}`,
|
|
@@ -1229,16 +1332,11 @@ async function fetchXaiWeeklyCredits(accessToken: string, userId: string): Promi
|
|
|
1229
1332
|
}
|
|
1230
1333
|
}
|
|
1231
1334
|
|
|
1232
|
-
async function fetchXaiQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
1233
|
-
|
|
1234
|
-
try {
|
|
1235
|
-
accessToken = await getValidAccessToken("xai");
|
|
1236
|
-
} catch {
|
|
1237
|
-
return null;
|
|
1238
|
-
}
|
|
1335
|
+
async function fetchXaiQuota(provider: string, context: { accessToken: string; upstreamAccountId?: string }): Promise<ProviderQuotaReport | null> {
|
|
1336
|
+
const { accessToken } = context;
|
|
1239
1337
|
|
|
1240
1338
|
// Prefer the SuperGrok weekly credits window that actually gates prompting (#1283).
|
|
1241
|
-
const userId =
|
|
1339
|
+
const userId = context.upstreamAccountId?.trim() || xaiUserIdFromAccessToken(accessToken);
|
|
1242
1340
|
if (userId) {
|
|
1243
1341
|
const weekly = await fetchXaiWeeklyCredits(accessToken, userId);
|
|
1244
1342
|
if (weekly) return report(provider, "xai:grok-billing-credits", weekly);
|
|
@@ -1247,6 +1345,7 @@ async function fetchXaiQuota(provider: string): Promise<ProviderQuotaReport | nu
|
|
|
1247
1345
|
// Legacy monthly dollar pool — retained when weekly is unavailable.
|
|
1248
1346
|
try {
|
|
1249
1347
|
const response = await fetch(XAI_BILLING_URL, {
|
|
1348
|
+
redirect: "error",
|
|
1250
1349
|
headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
|
|
1251
1350
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1252
1351
|
});
|
|
@@ -1409,6 +1508,29 @@ async function fetchKiroQuota(provider: string): Promise<ProviderQuotaReport | n
|
|
|
1409
1508
|
return report(provider, "kiro:usage-limits", snapshot.quota);
|
|
1410
1509
|
}
|
|
1411
1510
|
|
|
1511
|
+
/**
|
|
1512
|
+
* Provider-level row for a passive provider: the ACTIVE account's last observed
|
|
1513
|
+
* subscription windows, the same shape `fetchAnthropicQuota` and `fetchKiroQuota`
|
|
1514
|
+
* return.
|
|
1515
|
+
*
|
|
1516
|
+
* Cache-only. A dashboard load or `ocx account refresh` must never spend an inference
|
|
1517
|
+
* turn, so `forceRefresh` does not exist on this path — there is nothing to refresh.
|
|
1518
|
+
* `report.updatedAt` is the observation time, which is what both GUI surfaces render
|
|
1519
|
+
* as the relative age of the row.
|
|
1520
|
+
*/
|
|
1521
|
+
async function fetchPassiveProviderQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
1522
|
+
const activeId = getAccountSet(provider)?.activeAccountId;
|
|
1523
|
+
if (!activeId) return null;
|
|
1524
|
+
// Idempotent; without it a proxy restart shows nothing until the next streaming turn
|
|
1525
|
+
// even though the last observation is on disk.
|
|
1526
|
+
hydrateAccountQuotaCache();
|
|
1527
|
+
const entry = accountQuotaCache.get(accountCacheKey(provider, activeId));
|
|
1528
|
+
if (!entry?.quota) return null;
|
|
1529
|
+
const built = report(provider, `${provider}:subscription-observation`, entry.quota);
|
|
1530
|
+
// Tagged here rather than inside report(), which every probed path shares.
|
|
1531
|
+
return built ? { ...built, observed: true } : null;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1412
1534
|
// ---------------------------------------------------------------------------
|
|
1413
1535
|
// Per-account quota (multiauth)
|
|
1414
1536
|
// ---------------------------------------------------------------------------
|
|
@@ -1426,8 +1548,12 @@ type AccountQuotaCacheEntry = {
|
|
|
1426
1548
|
quota: ProviderQuota | null;
|
|
1427
1549
|
/** Last probe failed (429 / network / expired login); still may hold last-good quota. */
|
|
1428
1550
|
unavailable?: true;
|
|
1551
|
+
/** Private new-reader identity; never persisted or serialized. */
|
|
1552
|
+
identity?: string;
|
|
1553
|
+
isCurrent?: () => boolean;
|
|
1429
1554
|
};
|
|
1430
1555
|
const accountQuotaCache = new Map<string, AccountQuotaCacheEntry>();
|
|
1556
|
+
let explicitAccountEpoch = 0;
|
|
1431
1557
|
|
|
1432
1558
|
/**
|
|
1433
1559
|
* Seed the cache from the last run, once.
|
|
@@ -1471,11 +1597,21 @@ export interface ProviderAccountQuota {
|
|
|
1471
1597
|
quota: ProviderQuota | null;
|
|
1472
1598
|
/** Set when the probe could not reach upstream (expired login, 429, network). */
|
|
1473
1599
|
unavailable?: true;
|
|
1600
|
+
isCurrent?: () => boolean;
|
|
1474
1601
|
}
|
|
1475
1602
|
|
|
1476
1603
|
/** Providers whose per-account quota can be probed. Extend as other OAuth APIs are covered. */
|
|
1477
1604
|
export function supportsPerAccountQuota(provider: string): boolean {
|
|
1478
|
-
return provider === "anthropic" || provider === "kiro" || provider === "google-antigravity"
|
|
1605
|
+
return provider === "anthropic" || provider === "kiro" || provider === "google-antigravity"
|
|
1606
|
+
|| explicitAccountReader(provider);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function explicitAccountReader(provider: string): boolean {
|
|
1610
|
+
return provider === "xai" || provider === "cursor" || provider === "kimi" || provider === "command-code";
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
export function providerOAuthAccountQuotaMode(provider: string): AccountQuotaMode {
|
|
1614
|
+
return hasPassiveAccountQuota(provider) ? "passive" : supportsPerAccountQuota(provider) ? "probe" : "unsupported";
|
|
1479
1615
|
}
|
|
1480
1616
|
|
|
1481
1617
|
function accountCacheKey(provider: string, accountId: string): string {
|
|
@@ -1488,6 +1624,7 @@ function accountCacheKey(provider: string, accountId: string): string {
|
|
|
1488
1624
|
*/
|
|
1489
1625
|
export function getCachedProviderAccountQuota(provider: string, accountId: string): ProviderQuota | null {
|
|
1490
1626
|
const entry = accountQuotaCache.get(accountCacheKey(provider, accountId));
|
|
1627
|
+
if (entry?.isCurrent && !entry.isCurrent()) return null;
|
|
1491
1628
|
return entry?.quota ?? null;
|
|
1492
1629
|
}
|
|
1493
1630
|
|
|
@@ -1505,6 +1642,75 @@ export function setCachedProviderAccountQuotaForTests(
|
|
|
1505
1642
|
accountQuotaCache.set(key, { ts: Date.now(), quota });
|
|
1506
1643
|
}
|
|
1507
1644
|
|
|
1645
|
+
/**
|
|
1646
|
+
* Providers whose per-account quota is OBSERVED in-band, never probed.
|
|
1647
|
+
*
|
|
1648
|
+
* Deliberately separate from `supportsPerAccountQuota` rather than folded into it. That
|
|
1649
|
+
* predicate gates explicit upstream readers. Meta publishes no quota endpoint, so it
|
|
1650
|
+
* remains a cache-only observation even when every probe reader is account-scoped.
|
|
1651
|
+
*/
|
|
1652
|
+
export function hasPassiveAccountQuota(provider: string): boolean {
|
|
1653
|
+
return provider === "meta-muse";
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/**
|
|
1657
|
+
* Record a quota observed in-band on a streaming turn.
|
|
1658
|
+
*
|
|
1659
|
+
* The CALLER captures `writerGeneration` when it resolves the serving credential, not
|
|
1660
|
+
* this function at write time. A streaming turn is a long await, and a generation
|
|
1661
|
+
* captured immediately before the write cannot see a config or account change that
|
|
1662
|
+
* happened EARLIER in the same turn — which is exactly the case the fence exists for.
|
|
1663
|
+
*/
|
|
1664
|
+
export function recordPassiveAccountQuota(
|
|
1665
|
+
provider: string,
|
|
1666
|
+
accountId: string,
|
|
1667
|
+
quota: ProviderQuota,
|
|
1668
|
+
writerGeneration: number,
|
|
1669
|
+
): void {
|
|
1670
|
+
if (!hasPassiveAccountQuota(provider) || !accountId) return;
|
|
1671
|
+
const key = accountCacheKey(provider, accountId);
|
|
1672
|
+
if (!mayCommitAccountQuotaKey(key, writerGeneration)) return;
|
|
1673
|
+
// Hydrate BEFORE writing, not only on the read path. `persistAccountQuotaCache`
|
|
1674
|
+
// serializes the whole in-memory map, so a passive write that lands before anything
|
|
1675
|
+
// has read the cache would persist this one row and erase every other provider's
|
|
1676
|
+
// saved row -- and `diskHydrated` would then stop any later reader from recovering
|
|
1677
|
+
// them. A probe writer cannot hit this because its own read hydrates first; an
|
|
1678
|
+
// observation arrives unprompted, so it must hydrate itself.
|
|
1679
|
+
hydrateAccountQuotaCache();
|
|
1680
|
+
accountQuotaCache.set(key, { ts: Date.now(), quota });
|
|
1681
|
+
// Persisted so a restart keeps the last observation: with no probe to re-establish it,
|
|
1682
|
+
// a forgotten row stays forgotten until the user happens to run another streaming turn.
|
|
1683
|
+
persistAccountQuotaCache();
|
|
1684
|
+
// sweepExpiredOnWrite is deliberately NOT called. Existing probe writers call it
|
|
1685
|
+
// because they run on a poll; this runs on the request path, where a state sweep does
|
|
1686
|
+
// not belong. Passive rows are still reclaimed by generation reconciliation
|
|
1687
|
+
// (reconcileProviderAccountQuotaRows) and by the disk reader's age bound.
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/**
|
|
1691
|
+
* Cache-only per-account rows for a passive provider. Never probes, never refreshes.
|
|
1692
|
+
*
|
|
1693
|
+
* An account with no observation is OMITTED rather than returned with `quota: null` and
|
|
1694
|
+
* `unavailable`: that pair means "a probe was attempted and failed", and no probe was
|
|
1695
|
+
* ever attempted here. A user who has not yet run a streaming turn simply has no
|
|
1696
|
+
* measurement, which is not an error state.
|
|
1697
|
+
*/
|
|
1698
|
+
export function readPassiveProviderAccountQuotas(provider: string): ProviderAccountQuota[] {
|
|
1699
|
+
if (!hasPassiveAccountQuota(provider)) return [];
|
|
1700
|
+
// Idempotent, and otherwise only reached from probe paths a passive provider never
|
|
1701
|
+
// enters — without it a restart shows nothing until the next streaming turn, even
|
|
1702
|
+
// though the row is sitting on disk.
|
|
1703
|
+
hydrateAccountQuotaCache();
|
|
1704
|
+
const set = getAccountSet(provider);
|
|
1705
|
+
if (!set) return [];
|
|
1706
|
+
const rows: ProviderAccountQuota[] = [];
|
|
1707
|
+
for (const account of set.accounts) {
|
|
1708
|
+
const entry = accountQuotaCache.get(accountCacheKey(provider, account.id));
|
|
1709
|
+
if (entry?.quota) rows.push({ accountId: account.id, quota: entry.quota });
|
|
1710
|
+
}
|
|
1711
|
+
return rows;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1508
1714
|
export function sweepExpiredProviderAccountQuotaRows(now = Date.now()): number {
|
|
1509
1715
|
let removed = 0;
|
|
1510
1716
|
for (const [key, entry] of accountQuotaCache) {
|
|
@@ -1547,6 +1753,7 @@ export function resetProviderQuotaReconcileStateForTests(): void {
|
|
|
1547
1753
|
|
|
1548
1754
|
/** Drop cached per-account rows (all, or just one provider's). */
|
|
1549
1755
|
export function clearAccountQuotaCache(provider?: string): void {
|
|
1756
|
+
explicitAccountEpoch += 1;
|
|
1550
1757
|
if (!provider) {
|
|
1551
1758
|
accountQuotaCache.clear();
|
|
1552
1759
|
accountQuotaInflight.clear();
|
|
@@ -1591,11 +1798,115 @@ async function getTokenForAccountQuotaProbe(provider: string, accountId: string)
|
|
|
1591
1798
|
return getValidAccessTokenForAccount(provider, accountId);
|
|
1592
1799
|
}
|
|
1593
1800
|
|
|
1801
|
+
function explicitQuotaConfig(provider: string, configured?: OcxProviderConfig): OcxProviderConfig | undefined {
|
|
1802
|
+
if (configured) return configured;
|
|
1803
|
+
const entry = getProviderRegistryEntry(provider);
|
|
1804
|
+
return entry ? { adapter: entry.adapter, baseUrl: entry.baseUrl, authMode: "oauth" } : undefined;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
function explicitQuotaIdentity(provider: string, accountId: string, configured?: OcxProviderConfig): string | undefined {
|
|
1808
|
+
const credential = getAccountCredential(provider, accountId);
|
|
1809
|
+
const target = explicitQuotaConfig(provider, configured);
|
|
1810
|
+
if (!credential || !target) return undefined;
|
|
1811
|
+
return createHash("sha256").update(JSON.stringify([
|
|
1812
|
+
provider, accountId, credential.access, credential.refresh, credential.expires,
|
|
1813
|
+
credential.accountId, credential.projectId, credential.source,
|
|
1814
|
+
target.adapter, target.baseUrl, target.authMode, target.disabled === true,
|
|
1815
|
+
])).digest("hex");
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
function explicitQuotaDestination(provider: string, config: OcxProviderConfig): boolean {
|
|
1819
|
+
if (config.disabled === true || config.authMode !== "oauth") return false;
|
|
1820
|
+
if (provider === "kimi") return isCanonicalKimiCodeBaseUrl(config.baseUrl);
|
|
1821
|
+
if (provider === "command-code") return isCanonicalCommandCodeBaseUrl(config.baseUrl);
|
|
1822
|
+
// These readers use fixed canonical billing origins, never config.baseUrl.
|
|
1823
|
+
return provider === "xai" || provider === "cursor";
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
async function readExplicitAccountQuota(provider: string, accountId: string, configured?: OcxProviderConfig): Promise<{
|
|
1827
|
+
result: ProviderQuotaProbeResult;
|
|
1828
|
+
identity: string | undefined;
|
|
1829
|
+
isCurrent: () => boolean;
|
|
1830
|
+
} | null> {
|
|
1831
|
+
const target = explicitQuotaConfig(provider, configured);
|
|
1832
|
+
if (!target || !explicitQuotaDestination(provider, target)) return null;
|
|
1833
|
+
const config = { ...target };
|
|
1834
|
+
const epoch = explicitAccountEpoch;
|
|
1835
|
+
const accessToken = await getTokenForAccountQuotaProbe(provider, accountId);
|
|
1836
|
+
const credential = getAccountCredential(provider, accountId);
|
|
1837
|
+
if (!credential || credential.access !== accessToken) return null;
|
|
1838
|
+
// Pair the post-renewal credential with the destination captured before renewal.
|
|
1839
|
+
const identity = explicitQuotaIdentity(provider, accountId, config);
|
|
1840
|
+
const isCurrent = () => epoch === explicitAccountEpoch
|
|
1841
|
+
&& identity === explicitQuotaIdentity(provider, accountId, configured);
|
|
1842
|
+
if (!isCurrent()) return null;
|
|
1843
|
+
let result: ProviderQuotaProbeResult;
|
|
1844
|
+
switch (provider) {
|
|
1845
|
+
case "xai": result = await fetchXaiQuota(provider, { accessToken, upstreamAccountId: credential.accountId }); break;
|
|
1846
|
+
case "cursor": result = await fetchCursorQuota(provider, accessToken); break;
|
|
1847
|
+
case "kimi": result = await fetchKimiQuota(provider, config, accessToken); break;
|
|
1848
|
+
case "command-code": result = await fetchCommandCodeQuota(provider, config, accessToken); break;
|
|
1849
|
+
default: return null;
|
|
1850
|
+
}
|
|
1851
|
+
return { result, identity, isCurrent };
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
async function fetchExplicitAccountQuota(provider: string, accountId: string, force: boolean, configured?: OcxProviderConfig): Promise<AccountQuotaCacheEntry> {
|
|
1855
|
+
const key = accountCacheKey(provider, accountId);
|
|
1856
|
+
const identity = explicitQuotaIdentity(provider, accountId, configured);
|
|
1857
|
+
const previous = accountQuotaCache.get(key);
|
|
1858
|
+
const cached = identity && previous?.identity === identity && previous.isCurrent?.() ? previous : undefined;
|
|
1859
|
+
if (!force && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS
|
|
1860
|
+
&& (!cached.quota || Date.now() - cached.quota.updatedAt < LAST_GOOD_MAX_AGE_MS)) return cached;
|
|
1861
|
+
const flightKey = `${key}\u0000${identity ?? "missing"}`;
|
|
1862
|
+
const running = accountQuotaInflight.get(flightKey);
|
|
1863
|
+
if (running) return running;
|
|
1864
|
+
const epoch = explicitAccountEpoch;
|
|
1865
|
+
const lastGood = cached?.quota && Date.now() - cached.quota.updatedAt < LAST_GOOD_MAX_AGE_MS ? cached.quota : null;
|
|
1866
|
+
const flight = (async (): Promise<AccountQuotaCacheEntry> => {
|
|
1867
|
+
let read: Awaited<ReturnType<typeof readExplicitAccountQuota>> = null;
|
|
1868
|
+
try { read = await readExplicitAccountQuota(provider, accountId, configured); } catch { /* unavailable */ }
|
|
1869
|
+
const isCurrent = read?.isCurrent ?? (() => epoch === explicitAccountEpoch && !!identity
|
|
1870
|
+
&& identity === explicitQuotaIdentity(provider, accountId, configured));
|
|
1871
|
+
const result = read?.result;
|
|
1872
|
+
const current = epoch === explicitAccountEpoch && isCurrent();
|
|
1873
|
+
const quota = current && result && typeof result !== "symbol" ? result.quota : null;
|
|
1874
|
+
const empty = result === AUTHORITATIVE_EMPTY_QUOTA;
|
|
1875
|
+
const entry: AccountQuotaCacheEntry = {
|
|
1876
|
+
ts: Date.now(),
|
|
1877
|
+
quota: quota ?? (current && result !== TERMINAL_QUOTA_FAILURE && !empty
|
|
1878
|
+
&& lastGood && Date.now() - lastGood.updatedAt < LAST_GOOD_MAX_AGE_MS ? lastGood : null),
|
|
1879
|
+
...(!current || (!quota && !empty) ? { unavailable: true as const } : {}),
|
|
1880
|
+
identity: read?.identity ?? identity,
|
|
1881
|
+
isCurrent: () => epoch === explicitAccountEpoch && isCurrent(),
|
|
1882
|
+
};
|
|
1883
|
+
if (entry.isCurrent?.()) accountQuotaCache.set(key, entry);
|
|
1884
|
+
return entry;
|
|
1885
|
+
})().finally(() => { if (accountQuotaInflight.get(flightKey) === flight) accountQuotaInflight.delete(flightKey); });
|
|
1886
|
+
accountQuotaInflight.set(flightKey, flight);
|
|
1887
|
+
return flight;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
async function fetchExplicitCurrentQuota(provider: string, config: OcxProviderConfig, liveConfig: OcxConfig): Promise<ProviderQuotaProbeResult> {
|
|
1891
|
+
const id = getAccountSet(provider)?.activeAccountId;
|
|
1892
|
+
if (!id) return null;
|
|
1893
|
+
const read = await readExplicitAccountQuota(provider, id, config);
|
|
1894
|
+
if (!read) return null;
|
|
1895
|
+
const isCurrent = () => liveConfig.providers[provider] === config
|
|
1896
|
+
&& read.isCurrent() && getAccountSet(provider)?.activeAccountId === id;
|
|
1897
|
+
if (!isCurrent()) return TERMINAL_QUOTA_FAILURE;
|
|
1898
|
+
if (read.result && typeof read.result !== "symbol") accountReportCurrent.set(read.result, isCurrent);
|
|
1899
|
+
return read.result;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1594
1902
|
async function fetchAccountQuota(
|
|
1595
1903
|
provider: string,
|
|
1596
1904
|
accountId: string,
|
|
1597
1905
|
forceRefresh: boolean,
|
|
1906
|
+
providerConfig?: OcxProviderConfig,
|
|
1598
1907
|
): Promise<AccountQuotaCacheEntry> {
|
|
1908
|
+
if (!supportsPerAccountQuota(provider)) return { ts: Date.now(), quota: null, unavailable: true };
|
|
1909
|
+
if (explicitAccountReader(provider)) return fetchExplicitAccountQuota(provider, accountId, forceRefresh, providerConfig);
|
|
1599
1910
|
const key = accountCacheKey(provider, accountId);
|
|
1600
1911
|
const writerGeneration = captureConfigGeneration();
|
|
1601
1912
|
const cached = accountQuotaCache.get(key);
|
|
@@ -1625,8 +1936,10 @@ async function fetchAccountQuota(
|
|
|
1625
1936
|
const projectId = getAccountCredential(provider, accountId)?.projectId;
|
|
1626
1937
|
if (!projectId) throw new Error("antigravity account has no project id");
|
|
1627
1938
|
quota = await fetchAntigravityUsageQuota(token, projectId);
|
|
1628
|
-
} else {
|
|
1939
|
+
} else if (provider === "anthropic") {
|
|
1629
1940
|
quota = await fetchAnthropicUsageQuota(token);
|
|
1941
|
+
} else {
|
|
1942
|
+
return { ts: Date.now(), quota: null, unavailable: true };
|
|
1630
1943
|
}
|
|
1631
1944
|
}
|
|
1632
1945
|
if (!quota) {
|
|
@@ -1679,18 +1992,27 @@ async function fetchAccountQuota(
|
|
|
1679
1992
|
export async function fetchProviderAccountQuotas(
|
|
1680
1993
|
provider: string,
|
|
1681
1994
|
forceRefresh = false,
|
|
1995
|
+
providerConfig?: OcxProviderConfig,
|
|
1682
1996
|
): Promise<ProviderAccountQuota[]> {
|
|
1683
1997
|
if (!supportsPerAccountQuota(provider)) return [];
|
|
1684
1998
|
const set = getAccountSet(provider);
|
|
1685
1999
|
if (!set) return [];
|
|
1686
|
-
return
|
|
1687
|
-
const entry = await fetchAccountQuota(provider, account.id, forceRefresh);
|
|
1688
|
-
|
|
2000
|
+
return mapQuotaRoster(set.accounts, async account => {
|
|
2001
|
+
const entry = await fetchAccountQuota(provider, account.id, forceRefresh, providerConfig);
|
|
2002
|
+
const result: ProviderAccountQuota = {
|
|
1689
2003
|
accountId: account.id,
|
|
1690
2004
|
quota: entry.quota,
|
|
1691
2005
|
...(entry.unavailable ? { unavailable: true as const } : {}),
|
|
1692
2006
|
};
|
|
1693
|
-
|
|
2007
|
+
if (!explicitAccountReader(provider)) return result;
|
|
2008
|
+
const identity = entry.identity;
|
|
2009
|
+
Object.defineProperty(result, "isCurrent", { value: () => {
|
|
2010
|
+
if (entry.isCurrent) return entry.isCurrent();
|
|
2011
|
+
const credential = getAccountCredential(provider, account.id);
|
|
2012
|
+
return !!credential && (!identity || explicitQuotaIdentity(provider, account.id, providerConfig) === identity);
|
|
2013
|
+
} });
|
|
2014
|
+
return result;
|
|
2015
|
+
});
|
|
1694
2016
|
}
|
|
1695
2017
|
|
|
1696
2018
|
function normalizedBaseUrl(value: string): string | null {
|
|
@@ -1809,10 +2131,10 @@ function parseKimiQuotaPayload(value: unknown): ProviderQuota | null {
|
|
|
1809
2131
|
return hasQuotaRows(quota) ? quota : null;
|
|
1810
2132
|
}
|
|
1811
2133
|
|
|
1812
|
-
async function resolveKimiQuotaBearer(config: OcxProviderConfig): Promise<string | null> {
|
|
2134
|
+
async function resolveKimiQuotaBearer(config: OcxProviderConfig, accountId?: string): Promise<string | null> {
|
|
1813
2135
|
if (config.authMode === "oauth") {
|
|
1814
2136
|
try {
|
|
1815
|
-
return await
|
|
2137
|
+
return accountId ? await getTokenForAccountQuotaProbe("kimi", accountId) : null;
|
|
1816
2138
|
} catch {
|
|
1817
2139
|
return null;
|
|
1818
2140
|
}
|
|
@@ -1824,13 +2146,13 @@ async function resolveKimiQuotaBearer(config: OcxProviderConfig): Promise<string
|
|
|
1824
2146
|
return primary || null;
|
|
1825
2147
|
}
|
|
1826
2148
|
|
|
1827
|
-
async function fetchKimiQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaReport | null> {
|
|
2149
|
+
async function fetchKimiQuota(provider: string, config: OcxProviderConfig, accessToken: string): Promise<ProviderQuotaReport | null> {
|
|
1828
2150
|
// Never release credentials to a user-edited or lookalike provider host.
|
|
1829
2151
|
if (!isCanonicalKimiCodeBaseUrl(config.baseUrl)) return null;
|
|
1830
|
-
const accessToken = await resolveKimiQuotaBearer(config);
|
|
1831
2152
|
if (!accessToken) return null;
|
|
1832
2153
|
const response = await fetch(KIMI_CODE_USAGE_URL, {
|
|
1833
2154
|
headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
|
|
2155
|
+
redirect: "error",
|
|
1834
2156
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1835
2157
|
});
|
|
1836
2158
|
if (!response.ok) return null;
|
|
@@ -1917,10 +2239,10 @@ async function fetchCommandCodeSpend(
|
|
|
1917
2239
|
}
|
|
1918
2240
|
|
|
1919
2241
|
/** OAuth access token or ACTIVE Provider-API key for the Command Code quota probe. */
|
|
1920
|
-
async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig): Promise<string | null> {
|
|
2242
|
+
async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig, accountId?: string): Promise<string | null> {
|
|
1921
2243
|
if (config.authMode === "oauth") {
|
|
1922
2244
|
try {
|
|
1923
|
-
return await
|
|
2245
|
+
return accountId ? await getTokenForAccountQuotaProbe("command-code", accountId) : null;
|
|
1924
2246
|
} catch {
|
|
1925
2247
|
return null;
|
|
1926
2248
|
}
|
|
@@ -1935,10 +2257,9 @@ async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig): Promise
|
|
|
1935
2257
|
* usage view uses (windowLimits.fiveHour / windowLimits.weekly), plus soft
|
|
1936
2258
|
* whoami (team orgId scoping) and subscription-scoped spend for creditsUsd.
|
|
1937
2259
|
*/
|
|
1938
|
-
async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
2260
|
+
async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig, bearer: string): Promise<ProviderQuotaProbeResult> {
|
|
1939
2261
|
// Never release credentials to a user-edited or lookalike provider host.
|
|
1940
2262
|
if (!isCanonicalCommandCodeBaseUrl(config.baseUrl)) return null;
|
|
1941
|
-
const bearer = await resolveCommandCodeQuotaBearer(config);
|
|
1942
2263
|
if (!bearer) return null;
|
|
1943
2264
|
const whoamiBody = await fetchCommandCodeJson(COMMAND_CODE_WHOAMI_URL, bearer);
|
|
1944
2265
|
const whoami = asRecord(whoamiBody?.data) ?? whoamiBody;
|
|
@@ -1978,13 +2299,7 @@ async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig
|
|
|
1978
2299
|
}
|
|
1979
2300
|
|
|
1980
2301
|
/** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */
|
|
1981
|
-
async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
1982
|
-
let accessToken: string;
|
|
1983
|
-
try {
|
|
1984
|
-
accessToken = await getValidAccessToken("cursor");
|
|
1985
|
-
} catch {
|
|
1986
|
-
return null;
|
|
1987
|
-
}
|
|
2302
|
+
async function fetchCursorQuota(provider: string, accessToken: string): Promise<ProviderQuotaReport | null> {
|
|
1988
2303
|
|
|
1989
2304
|
const authHeaders = {
|
|
1990
2305
|
Accept: "application/json",
|
|
@@ -1997,6 +2312,7 @@ async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport |
|
|
|
1997
2312
|
try {
|
|
1998
2313
|
const periodRes = await fetch("https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", {
|
|
1999
2314
|
method: "POST",
|
|
2315
|
+
redirect: "error",
|
|
2000
2316
|
headers: {
|
|
2001
2317
|
...authHeaders,
|
|
2002
2318
|
"Content-Type": "application/json",
|
|
@@ -2065,6 +2381,7 @@ async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport |
|
|
|
2065
2381
|
try {
|
|
2066
2382
|
const summaryRes = await fetch("https://api2.cursor.sh/api/usage/summary", {
|
|
2067
2383
|
headers: authHeaders,
|
|
2384
|
+
redirect: "error",
|
|
2068
2385
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
2069
2386
|
});
|
|
2070
2387
|
if (summaryRes.ok) {
|
|
@@ -2094,6 +2411,7 @@ async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport |
|
|
|
2094
2411
|
|
|
2095
2412
|
const response = await fetch("https://api2.cursor.sh/auth/usage", {
|
|
2096
2413
|
headers: authHeaders,
|
|
2414
|
+
redirect: "error",
|
|
2097
2415
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
2098
2416
|
});
|
|
2099
2417
|
if (!response.ok) return null;
|
|
@@ -2180,10 +2498,11 @@ function classifyAntigravityFamily(modelId: string, modelInfo: Record<string, un
|
|
|
2180
2498
|
}
|
|
2181
2499
|
|
|
2182
2500
|
function antigravityUsedPercent(quotaInfo: Record<string, unknown>): number | undefined {
|
|
2183
|
-
const
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2501
|
+
const target = asRecord(quotaInfo.remaining) ?? quotaInfo;
|
|
2502
|
+
const remaining = normalizePercent(toFiniteNumber(target.remainingFraction) !== undefined
|
|
2503
|
+
? toFiniteNumber(target.remainingFraction)! * 100
|
|
2504
|
+
: toFiniteNumber(target.remainingPercentage) !== undefined
|
|
2505
|
+
? toFiniteNumber(target.remainingPercentage)! * 100
|
|
2187
2506
|
: undefined);
|
|
2188
2507
|
if (remaining === undefined) return undefined;
|
|
2189
2508
|
return normalizePercent(100 - remaining);
|
|
@@ -2218,6 +2537,75 @@ function antigravityWindowsFromModels(body: Record<string, unknown> | null): Pro
|
|
|
2218
2537
|
return customWindows;
|
|
2219
2538
|
}
|
|
2220
2539
|
|
|
2540
|
+
/**
|
|
2541
|
+
* Parse Google Antigravity quota from `v1internal:retrieveUserQuotaSummary`.
|
|
2542
|
+
* Groups contain Gemini models and Claude/3P models, each with 5h and weekly limit buckets.
|
|
2543
|
+
*/
|
|
2544
|
+
function parseAntigravityQuotaSummary(body: Record<string, unknown> | null): ProviderQuota | null {
|
|
2545
|
+
const groups = Array.isArray(body?.groups) ? (body.groups as unknown[]) : [];
|
|
2546
|
+
if (groups.length === 0) return null;
|
|
2547
|
+
|
|
2548
|
+
const customWindowsMap = new Map<string, ProviderQuotaWindow>();
|
|
2549
|
+
|
|
2550
|
+
for (const rawGroup of groups) {
|
|
2551
|
+
const group = asRecord(rawGroup);
|
|
2552
|
+
if (!group) continue;
|
|
2553
|
+
const groupName = `${typeof group.displayName === "string" ? group.displayName : ""} ${typeof group.description === "string" ? group.description : ""}`.toLowerCase();
|
|
2554
|
+
const isGemini = groupName.includes("gemini");
|
|
2555
|
+
const isClaude = groupName.includes("claude") || groupName.includes("3p") || groupName.includes("gpt");
|
|
2556
|
+
|
|
2557
|
+
const buckets = Array.isArray(group.buckets) ? (group.buckets as unknown[]) : [];
|
|
2558
|
+
for (const rawBucket of buckets) {
|
|
2559
|
+
const bucket = asRecord(rawBucket);
|
|
2560
|
+
if (!bucket) continue;
|
|
2561
|
+
const windowStr = `${typeof bucket.window === "string" ? bucket.window : ""} ${typeof bucket.bucketId === "string" ? bucket.bucketId : ""} ${typeof bucket.displayName === "string" ? bucket.displayName : ""}`.toLowerCase();
|
|
2562
|
+
const percent = antigravityUsedPercent(bucket);
|
|
2563
|
+
if (percent === undefined) continue;
|
|
2564
|
+
const resetAt = normalizeResetAt(bucket.resetTime);
|
|
2565
|
+
|
|
2566
|
+
const isWeekly = windowStr.includes("week");
|
|
2567
|
+
const is5h = windowStr.includes("5h") || windowStr.includes("five");
|
|
2568
|
+
|
|
2569
|
+
if (isGemini) {
|
|
2570
|
+
const label = is5h ? "Gem" : isWeekly ? "Gem (Weekly)" : "";
|
|
2571
|
+
if (label && !customWindowsMap.has(label)) {
|
|
2572
|
+
customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) });
|
|
2573
|
+
}
|
|
2574
|
+
} else if (isClaude) {
|
|
2575
|
+
const label = is5h ? "Cla" : isWeekly ? "Cla (Weekly)" : "";
|
|
2576
|
+
if (label && !customWindowsMap.has(label)) {
|
|
2577
|
+
customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) });
|
|
2578
|
+
}
|
|
2579
|
+
} else {
|
|
2580
|
+
const baseLabel = typeof group.displayName === "string" ? group.displayName : "Other";
|
|
2581
|
+
const label = isWeekly ? `${baseLabel} (Weekly)` : baseLabel;
|
|
2582
|
+
if (!customWindowsMap.has(label)) {
|
|
2583
|
+
customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) });
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
const PREFERRED_ORDER = ["Gem", "Gem (Weekly)", "Cla", "Cla (Weekly)"];
|
|
2590
|
+
const customWindows = Array.from(customWindowsMap.values()).sort((a, b) => {
|
|
2591
|
+
const ia = PREFERRED_ORDER.indexOf(a.label);
|
|
2592
|
+
const ib = PREFERRED_ORDER.indexOf(b.label);
|
|
2593
|
+
if (ia !== -1 && ib !== -1) return ia - ib;
|
|
2594
|
+
if (ia !== -1) return -1;
|
|
2595
|
+
if (ib !== -1) return 1;
|
|
2596
|
+
return a.label.localeCompare(b.label);
|
|
2597
|
+
});
|
|
2598
|
+
|
|
2599
|
+
if (customWindows.length === 0) {
|
|
2600
|
+
return null;
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
return {
|
|
2604
|
+
customWindows,
|
|
2605
|
+
updatedAt: Date.now(),
|
|
2606
|
+
};
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2221
2609
|
const ANTIGRAVITY_ACCOUNT_QUOTA_BASE = "https://daily-cloudcode-pa.googleapis.com";
|
|
2222
2610
|
let antigravityOutboundDependencies: ProviderOutboundDependencies = {};
|
|
2223
2611
|
|
|
@@ -2234,6 +2622,28 @@ export function setAntigravityAccountQuotaTransportForTests(dependencies: Provid
|
|
|
2234
2622
|
* A redirect or non-2xx yields null (unavailable), never a partial row.
|
|
2235
2623
|
*/
|
|
2236
2624
|
export async function fetchAntigravityUsageQuota(accessToken: string, projectId: string): Promise<ProviderQuota | null> {
|
|
2625
|
+
const summaryUrl = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:retrieveUserQuotaSummary`;
|
|
2626
|
+
try {
|
|
2627
|
+
const summaryResponse = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, summaryUrl, {
|
|
2628
|
+
headers: {
|
|
2629
|
+
Accept: "application/json",
|
|
2630
|
+
"Content-Type": "application/json",
|
|
2631
|
+
"User-Agent": antigravityUserAgent(),
|
|
2632
|
+
Authorization: `Bearer ${accessToken}`,
|
|
2633
|
+
},
|
|
2634
|
+
body: JSON.stringify({ project: projectId }),
|
|
2635
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
2636
|
+
}, antigravityOutboundDependencies);
|
|
2637
|
+
if (await providerRedirectError(summaryResponse, summaryUrl)) return null;
|
|
2638
|
+
if (summaryResponse.status === 401 || summaryResponse.status === 403) return null;
|
|
2639
|
+
if (summaryResponse.ok) {
|
|
2640
|
+
const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(summaryResponse)));
|
|
2641
|
+
if (quota) return quota;
|
|
2642
|
+
}
|
|
2643
|
+
} catch {
|
|
2644
|
+
// Fallback to fetchAvailableModels on error
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2237
2647
|
const url = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:fetchAvailableModels`;
|
|
2238
2648
|
const response = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, {
|
|
2239
2649
|
headers: {
|
|
@@ -2262,6 +2672,35 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig
|
|
|
2262
2672
|
return null;
|
|
2263
2673
|
}
|
|
2264
2674
|
const baseUrl = (config.baseUrl || ANTIGRAVITY_ACCOUNT_QUOTA_BASE).replace(/\/+$/, "");
|
|
2675
|
+
|
|
2676
|
+
// The summary probe is pinned to Google's own host through the provider-outbound
|
|
2677
|
+
// transport, mirroring `fetchAntigravityUsageQuota` above: a configured `baseUrl` is a
|
|
2678
|
+
// routing choice for requests, not a second source of Google's accounting, and this
|
|
2679
|
+
// request carries the account bearer.
|
|
2680
|
+
const summaryUrl = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:retrieveUserQuotaSummary`;
|
|
2681
|
+
try {
|
|
2682
|
+
const summaryResponse = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, summaryUrl, {
|
|
2683
|
+
headers: {
|
|
2684
|
+
Accept: "application/json",
|
|
2685
|
+
"Content-Type": "application/json",
|
|
2686
|
+
"User-Agent": antigravityUserAgent(),
|
|
2687
|
+
Authorization: `Bearer ${accessToken}`,
|
|
2688
|
+
},
|
|
2689
|
+
body: JSON.stringify({ project: credential.projectId }),
|
|
2690
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
2691
|
+
}, antigravityOutboundDependencies);
|
|
2692
|
+
if (await providerRedirectError(summaryResponse, summaryUrl)) return null;
|
|
2693
|
+
if (summaryResponse.status === 401 || summaryResponse.status === 403) return null;
|
|
2694
|
+
if (summaryResponse.ok) {
|
|
2695
|
+
const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(summaryResponse)));
|
|
2696
|
+
if (quota) {
|
|
2697
|
+
return report(provider, "google-antigravity:retrieveUserQuotaSummary", quota);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
} catch {
|
|
2701
|
+
// Fallback on network/fetch error
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2265
2704
|
const response = await fetch(`${baseUrl}/v1internal:fetchAvailableModels`, {
|
|
2266
2705
|
method: "POST",
|
|
2267
2706
|
headers: {
|
|
@@ -2282,6 +2721,54 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig
|
|
|
2282
2721
|
});
|
|
2283
2722
|
}
|
|
2284
2723
|
|
|
2724
|
+
type KeyQuotaReader = (name: string, provider: OcxProviderConfig) => Promise<ProviderQuotaProbeResult>;
|
|
2725
|
+
|
|
2726
|
+
/** Same selector drives cheap capabilities and uncached reads; never resolves credentials. */
|
|
2727
|
+
function keyQuotaReaderForProvider(name: string, provider: OcxProviderConfig): KeyQuotaReader | null {
|
|
2728
|
+
if (provider.disabled === true || (provider.authMode ?? "key") !== "key") return null;
|
|
2729
|
+
if (isCanonicalKimiCodeBaseUrl(provider.baseUrl)) {
|
|
2730
|
+
return async (id, config) => {
|
|
2731
|
+
const bearer = await resolveKimiQuotaBearer(config);
|
|
2732
|
+
return bearer ? fetchKimiQuota(id, config, bearer) : null;
|
|
2733
|
+
};
|
|
2734
|
+
}
|
|
2735
|
+
if (name === "commandcode" && isCanonicalCommandCodeBaseUrl(provider.baseUrl)) {
|
|
2736
|
+
return async (id, config) => {
|
|
2737
|
+
const bearer = await resolveCommandCodeQuotaBearer(config);
|
|
2738
|
+
return bearer ? fetchCommandCodeQuota(id, config, bearer) : null;
|
|
2739
|
+
};
|
|
2740
|
+
}
|
|
2741
|
+
if (registryEntryForProviderDestination(provider)?.id === "opencode-go") return fetchOpenCodeGoQuota;
|
|
2742
|
+
if (isCanonicalA6apiBaseUrl(provider.baseUrl)) return fetchA6apiQuota;
|
|
2743
|
+
if (name === "openrouter" && isCanonicalOpenRouterBaseUrl(provider.baseUrl)) return fetchOpenRouterQuota;
|
|
2744
|
+
if (name === "deepseek" && isCanonicalDeepSeekBaseUrl(provider.baseUrl)) return fetchDeepSeekQuota;
|
|
2745
|
+
if (name === "cline-pass" && isCanonicalClineBaseUrl(provider.baseUrl)) return fetchClineQuota;
|
|
2746
|
+
if (isCanonicalOllamaCloudBaseUrl(provider.baseUrl ?? getProviderRegistryEntry(name)?.baseUrl)) return fetchOllamaCloudQuota;
|
|
2747
|
+
if (["zai", "glm", "glm-cn", "zhipu-bigmodel-coding"].includes(name) && isCanonicalZaiBaseUrl(provider.baseUrl)) return fetchZaiQuota;
|
|
2748
|
+
if (["minimax", "minimax-cn"].includes(name) && isCanonicalMinimaxBaseUrl(provider.baseUrl)) return fetchMinimaxQuota;
|
|
2749
|
+
if (name === "moonshot" && isCanonicalMoonshotBaseUrl(provider.baseUrl)) return fetchMoonshotQuota;
|
|
2750
|
+
if (name === "venice" && isCanonicalVeniceBaseUrl(provider.baseUrl)) return fetchVeniceQuota;
|
|
2751
|
+
if (name === "synthetic" && isCanonicalSyntheticBaseUrl(provider.baseUrl)) return fetchSyntheticQuota;
|
|
2752
|
+
if (name === "deepinfra" && isCanonicalDeepInfraBaseUrl(provider.baseUrl)) return fetchDeepInfraQuota;
|
|
2753
|
+
if (name === "neuralwatt" && isCanonicalNeuralwattBaseUrl(provider.baseUrl)) return fetchNeuralwattQuota;
|
|
2754
|
+
return null;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
export function providerApiKeyQuotaMode(name: string, provider: OcxProviderConfig): AccountQuotaMode {
|
|
2758
|
+
return keyQuotaReaderForProvider(name, provider) ? "probe" : "unsupported";
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
export async function fetchProviderApiKeyQuotas(config: OcxConfig, name: string, forceRefresh = false): Promise<ProviderApiKeyQuota[]> {
|
|
2762
|
+
const provider = config.providers[name];
|
|
2763
|
+
if (!provider || !keyQuotaReaderForProvider(name, provider)) return [];
|
|
2764
|
+
return readProviderApiKeyQuotas(config, name, forceRefresh, async (isolatedProvider, isolatedConfig) => {
|
|
2765
|
+
const result = await maybeFetchProviderQuota(name, isolatedProvider, isolatedConfig, false);
|
|
2766
|
+
if (result === TERMINAL_QUOTA_FAILURE) return { kind: "terminal" };
|
|
2767
|
+
if (result === AUTHORITATIVE_EMPTY_QUOTA) return { kind: "empty" };
|
|
2768
|
+
return result ? { kind: "quota", quota: result.quota } : { kind: "unavailable" };
|
|
2769
|
+
});
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2285
2772
|
async function maybeFetchProviderQuota(
|
|
2286
2773
|
name: string,
|
|
2287
2774
|
provider: OcxProviderConfig,
|
|
@@ -2294,76 +2781,108 @@ async function maybeFetchProviderQuota(
|
|
|
2294
2781
|
if (isBuiltInChatGptForwardProvider(name, provider)) {
|
|
2295
2782
|
return fetchChatGptForwardQuota(config, name, provider, forceRefresh, prefetchedCodexSnapshot);
|
|
2296
2783
|
}
|
|
2297
|
-
if (provider.authMode === "oauth" && name
|
|
2784
|
+
if (provider.authMode === "oauth" && explicitAccountReader(name)) return await fetchExplicitCurrentQuota(name, provider, config);
|
|
2298
2785
|
if (provider.authMode === "oauth" && name === "anthropic") return fetchAnthropicQuota(name);
|
|
2299
|
-
if (provider.authMode === "oauth" && name === "cursor") return fetchCursorQuota(name);
|
|
2300
2786
|
if (provider.authMode === "oauth" && name === "google-antigravity") return fetchAntigravityQuota(name, provider);
|
|
2301
2787
|
if (provider.authMode === "oauth" && name === "kiro") return fetchKiroQuota(name);
|
|
2302
|
-
//
|
|
2303
|
-
//
|
|
2304
|
-
if (provider.authMode === "oauth" && name
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
}
|
|
2308
|
-
// OAuth account login or Provider-API key only; forward/local modes carry no
|
|
2309
|
-
// credential of ours on the canonical host.
|
|
2310
|
-
if (provider.authMode === "oauth" && name === "command-code") {
|
|
2311
|
-
return fetchCommandCodeQuota(name, provider);
|
|
2312
|
-
}
|
|
2313
|
-
if ((provider.authMode ?? "key") === "key" && name === "commandcode"
|
|
2314
|
-
&& isCanonicalCommandCodeBaseUrl(provider.baseUrl)) {
|
|
2315
|
-
return fetchCommandCodeQuota(name, provider);
|
|
2316
|
-
}
|
|
2317
|
-
// Identify OpenCode Go by where it routes, not by what the row is called. Multi-account
|
|
2318
|
-
// setups keep the same destination under names like `opencode-go-2` (#1924), and those rows
|
|
2319
|
-
// silently had no quota panel and no `ocx provider quota --json` report while the literal
|
|
2320
|
-
// name was the gate. `registryEntryForProviderDestination` is the existing predicate for
|
|
2321
|
-
// exactly this question: normalized endpoint + adapter + key auth, so a canonical URL behind
|
|
2322
|
-
// a different adapter is still not OpenCode Go. The defensive URL check inside
|
|
2323
|
-
// `fetchOpenCodeGoQuota` stays — sending a key anywhere must not depend on this gate.
|
|
2324
|
-
if ((provider.authMode ?? "key") === "key" && registryEntryForProviderDestination(provider)?.id === "opencode-go") {
|
|
2325
|
-
return fetchOpenCodeGoQuota(name, provider);
|
|
2326
|
-
}
|
|
2327
|
-
if ((provider.authMode ?? "key") === "key" && isCanonicalA6apiBaseUrl(provider.baseUrl)) {
|
|
2328
|
-
return fetchA6apiQuota(name, provider);
|
|
2329
|
-
}
|
|
2330
|
-
if ((provider.authMode ?? "key") === "key" && name === "openrouter") {
|
|
2331
|
-
return fetchOpenRouterQuota(name, provider);
|
|
2332
|
-
}
|
|
2333
|
-
if ((provider.authMode ?? "key") === "key" && name === "deepseek") {
|
|
2334
|
-
return fetchDeepSeekQuota(name, provider);
|
|
2335
|
-
}
|
|
2336
|
-
if ((provider.authMode ?? "key") === "key" && name === "cline-pass") {
|
|
2337
|
-
return fetchClineQuota(name, provider);
|
|
2338
|
-
}
|
|
2339
|
-
if ((provider.authMode ?? "key") === "key"
|
|
2340
|
-
&& (name === "zai" || name === "glm" || name === "glm-cn" || name === "zhipu-bigmodel-coding")) {
|
|
2341
|
-
return fetchZaiQuota(name, provider);
|
|
2342
|
-
}
|
|
2343
|
-
if ((provider.authMode ?? "key") === "key" && (name === "minimax" || name === "minimax-cn")) {
|
|
2344
|
-
return fetchMinimaxQuota(name, provider);
|
|
2345
|
-
}
|
|
2346
|
-
if ((provider.authMode ?? "key") === "key" && name === "moonshot") {
|
|
2347
|
-
return fetchMoonshotQuota(name, provider);
|
|
2348
|
-
}
|
|
2349
|
-
if ((provider.authMode ?? "key") === "key" && name === "venice") {
|
|
2350
|
-
return fetchVeniceQuota(name, provider);
|
|
2351
|
-
}
|
|
2352
|
-
if ((provider.authMode ?? "key") === "key" && name === "synthetic") {
|
|
2353
|
-
return fetchSyntheticQuota(name, provider);
|
|
2354
|
-
}
|
|
2355
|
-
if ((provider.authMode ?? "key") === "key" && name === "deepinfra") {
|
|
2356
|
-
return fetchDeepInfraQuota(name, provider);
|
|
2357
|
-
}
|
|
2358
|
-
if ((provider.authMode ?? "key") === "key" && name === "neuralwatt") {
|
|
2359
|
-
return fetchNeuralwattQuota(name, provider);
|
|
2360
|
-
}
|
|
2361
|
-
return null;
|
|
2788
|
+
// Passive providers (meta-muse): Meta publishes no quota endpoint, so there is no
|
|
2789
|
+
// probe to run — the row is the active account's last in-band observation.
|
|
2790
|
+
if (provider.authMode === "oauth" && hasPassiveAccountQuota(name)) return fetchPassiveProviderQuota(name);
|
|
2791
|
+
const reader = keyQuotaReaderForProvider(name, provider);
|
|
2792
|
+
return reader ? reader(name, provider) : null;
|
|
2362
2793
|
} catch {
|
|
2363
2794
|
return null;
|
|
2364
2795
|
}
|
|
2365
2796
|
}
|
|
2366
2797
|
|
|
2798
|
+
/**
|
|
2799
|
+
* Hand freshly committed provider reports to the optional quota-reset observer.
|
|
2800
|
+
*
|
|
2801
|
+
* Lazy import on purpose: this module is statically reachable from
|
|
2802
|
+
* src/server/responses/core.ts (via oauth/anthropic-routing.ts), so a static edge would put
|
|
2803
|
+
* the observer and its sink registry on every install's request path.
|
|
2804
|
+
*
|
|
2805
|
+
* No previous snapshot is passed. The observer keeps its own persisted last-seen map,
|
|
2806
|
+
* because `previous` here is bound only when `cache.key === key` and the key digest at
|
|
2807
|
+
* cacheKeyWithAggregationState includes quota values and updatedAt — so it is empty exactly
|
|
2808
|
+
* when a reset happened.
|
|
2809
|
+
*
|
|
2810
|
+
* Account identity is resolved SYNCHRONOUSLY, before any await. Two reasons, both observed:
|
|
2811
|
+
* pool failover rewrites `activeAccountId` during request routing
|
|
2812
|
+
* (promoteAnthropicActiveAccount -> setActiveAccount), so a 429 between this commit and a
|
|
2813
|
+
* later async read would attribute this report to a different account and overwrite that
|
|
2814
|
+
* account's baseline with these numbers. fetchAnthropicQuota already captures
|
|
2815
|
+
* `probedAccountId` before awaiting for exactly this reason; the observer must not be
|
|
2816
|
+
* sloppier than the cache it observes.
|
|
2817
|
+
*
|
|
2818
|
+
* Observations are serialized through a module-level promise chain for the same reason as
|
|
2819
|
+
* the codex seam: Bun does not resolve concurrent import() calls in call order, and an
|
|
2820
|
+
* out-of-order baseline swap manufactures false resets that then burn the durable
|
|
2821
|
+
* idempotence key.
|
|
2822
|
+
*/
|
|
2823
|
+
let pendingProviderObservation: Promise<void> = Promise.resolve();
|
|
2824
|
+
|
|
2825
|
+
/**
|
|
2826
|
+
* Stable per-account observation key for one provider report.
|
|
2827
|
+
*
|
|
2828
|
+
* OAuth providers key by active account id. Key-auth providers have NO account set, so
|
|
2829
|
+
* every key in `apiKeyPool` would collapse onto "default" and rotating from a spent key to
|
|
2830
|
+
* a fresh one would read as a reset (measured: 97% -> 12% fired a false surprise). The
|
|
2831
|
+
* cache key already discriminates these at cacheKey() via apiKeyPoolEntryId, so this
|
|
2832
|
+
* mirrors that discriminator instead of inventing a second notion of identity.
|
|
2833
|
+
*/
|
|
2834
|
+
function providerObservationAccountKey(provider: string, config: OcxConfig): string {
|
|
2835
|
+
const oauthAccountId = getAccountSet(provider)?.activeAccountId;
|
|
2836
|
+
if (oauthAccountId !== undefined) return `${provider}\u0000${oauthAccountId}`;
|
|
2837
|
+
const providerConfig = config.providers[provider];
|
|
2838
|
+
const resolvedKey = typeof providerConfig?.apiKey === "string"
|
|
2839
|
+
? resolveEnvValue(providerConfig.apiKey)?.trim()
|
|
2840
|
+
: undefined;
|
|
2841
|
+
const keyId = resolvedKey ? apiKeyPoolEntryId(resolvedKey) : "default";
|
|
2842
|
+
return `${provider}\u0000key:${keyId}`;
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
/** Test-only view of the observation account key. */
|
|
2846
|
+
export function providerObservationAccountKeyForTests(provider: string, config: OcxConfig): string {
|
|
2847
|
+
return providerObservationAccountKey(provider, config);
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
function notifyProviderQuotaSnapshot(
|
|
2851
|
+
reports: ReadonlyArray<ProviderQuotaReport>,
|
|
2852
|
+
config: OcxConfig,
|
|
2853
|
+
): void {
|
|
2854
|
+
if (reports.length === 0) return;
|
|
2855
|
+
// Resolved here, synchronously, while the identity is still the one that produced these
|
|
2856
|
+
// reports.
|
|
2857
|
+
const observations = reports.map(report => ({
|
|
2858
|
+
scope: report.provider,
|
|
2859
|
+
accountKey: providerObservationAccountKey(report.provider, config),
|
|
2860
|
+
quota: report.quota,
|
|
2861
|
+
}));
|
|
2862
|
+
pendingProviderObservation = pendingProviderObservation
|
|
2863
|
+
.then(async () => {
|
|
2864
|
+
const observer = await import("../quota/reset-observer");
|
|
2865
|
+
if (!observer.hasQuotaResetSink()) return;
|
|
2866
|
+
const { providerWindowObservations } = await import("../quota/window-mapping");
|
|
2867
|
+
for (const observation of observations) {
|
|
2868
|
+
observer.observeQuotaSnapshot({
|
|
2869
|
+
scope: observation.scope,
|
|
2870
|
+
accountKey: observation.accountKey,
|
|
2871
|
+
windows: providerWindowObservations(observation.quota),
|
|
2872
|
+
});
|
|
2873
|
+
}
|
|
2874
|
+
})
|
|
2875
|
+
.catch(() => {
|
|
2876
|
+
// Detection is best-effort: a quota refresh must never fail because of it. Swallowing
|
|
2877
|
+
// here also keeps the chain alive — a rejected link would poison every later one.
|
|
2878
|
+
});
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
/** Await the observation chain. Tests only: production never needs to join it. */
|
|
2882
|
+
export function flushProviderQuotaObservationsForTests(): Promise<void> {
|
|
2883
|
+
return pendingProviderObservation;
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2367
2886
|
export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh = false): Promise<ProviderQuotaResponse> {
|
|
2368
2887
|
// A Pool report's cache signature and provider fetch must share one account snapshot.
|
|
2369
2888
|
// Preserve force semantics when deciding whether that snapshot refreshes upstream data.
|
|
@@ -2376,9 +2895,14 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh
|
|
|
2376
2895
|
const now = Date.now();
|
|
2377
2896
|
// The cache fast path must not extend a preserved last-good row past its 30-minute bound:
|
|
2378
2897
|
// a row preserved at age 29:59 plus a full 5-minute TTL would otherwise serve until ~35min.
|
|
2898
|
+
// An OBSERVED row is exempt: it carries the observation time, which is older than the bound
|
|
2899
|
+
// by construction and never becomes fresher on its own. Without the exemption a single
|
|
2900
|
+
// configured passive provider makes this predicate permanently false, so every dashboard
|
|
2901
|
+
// poll re-probes every OTHER provider upstream instead of serving the 5-minute cache.
|
|
2379
2902
|
const cacheFresh = cache && cache.key === key && now - cache.ts < CACHE_TTL_MS
|
|
2380
2903
|
&& cache.response.reports.every(item =>
|
|
2381
|
-
now - item.updatedAt < LAST_GOOD_MAX_AGE_MS
|
|
2904
|
+
(item.observed === true || now - item.updatedAt < LAST_GOOD_MAX_AGE_MS)
|
|
2905
|
+
&& isProviderQuotaReportCurrent(item));
|
|
2382
2906
|
if (!forceRefresh && cacheFresh) return cache!.response;
|
|
2383
2907
|
const joinable = inflight.get(key);
|
|
2384
2908
|
if (!forceRefresh && joinable && joinable.epoch === invalidationEpoch) return joinable.promise;
|
|
@@ -2422,7 +2946,9 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh
|
|
|
2422
2946
|
const byProvider = new Map<string, ProviderQuotaReport>();
|
|
2423
2947
|
const generationMismatchedProviders = new Set<string>();
|
|
2424
2948
|
for (const item of previous) {
|
|
2425
|
-
|
|
2949
|
+
// Same exemption as the fast path. A passive row reaching `previous` is not a probe
|
|
2950
|
+
// that went quiet — there is no probe — so age cannot condemn it.
|
|
2951
|
+
if (item.observed !== true && item.updatedAt < cutoff) continue;
|
|
2426
2952
|
if (isProviderQuotaReportCurrent(item)) byProvider.set(item.provider, item);
|
|
2427
2953
|
else generationMismatchedProviders.add(item.provider);
|
|
2428
2954
|
}
|
|
@@ -2451,6 +2977,7 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh
|
|
|
2451
2977
|
const reports = response.reports.filter(item => mayCommitProviderQuotaKey(item.provider, writerGeneration));
|
|
2452
2978
|
cache = { key, ts: Date.now(), response: { ...response, reports } };
|
|
2453
2979
|
replaceCachedProviderQuotas(reports);
|
|
2980
|
+
notifyProviderQuotaSnapshot(reports, config);
|
|
2454
2981
|
}
|
|
2455
2982
|
return response;
|
|
2456
2983
|
})();
|