@bitkyc08/opencodex 2.88.0 → 2.90.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/README.md +11 -1
- package/bin/ocx.mjs +15 -4
- package/gui/dist/assets/App-DgxkE6Md.js +58 -0
- package/gui/dist/assets/App-dnkJ85te.css +1 -0
- package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
- package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic/cache-retention.ts +117 -0
- package/src/adapters/anthropic/passthrough.ts +7 -4
- package/src/adapters/anthropic.ts +10 -3
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +14 -2
- package/src/adapters/coding-agent/protocol.ts +6 -0
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-http.ts +3 -2
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google-vertex-endpoint.ts +67 -0
- package/src/adapters/google.ts +62 -37
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +22 -13
- package/src/adapters/openai-responses/reasoning.ts +68 -1
- package/src/adapters/openai-responses/transport.ts +13 -1
- package/src/adapters/registry.ts +1 -1
- package/src/adapters/upstream-http-error.ts +3 -2
- package/src/adapters/zed.ts +7 -4
- package/src/bridge/internal.ts +11 -1
- package/src/bridge/response-json.ts +2 -2
- package/src/bridge/sse.ts +10 -4
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/claude/inbound.ts +12 -26
- package/src/claude/outbound.ts +5 -0
- package/src/cli/account-api.ts +13 -1
- package/src/cli/account-extended.ts +17 -5
- package/src/cli/account-key-quota.ts +4 -1
- package/src/cli/account.ts +1 -1
- package/src/cli/capabilities-agents-routing.ts +45 -8
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-provider-models.ts +5 -4
- package/src/cli/combo-result.ts +8 -2
- package/src/cli/combo.ts +17 -0
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/cli/help.ts +1 -1
- package/src/cli/message-command.ts +4 -1
- package/src/cli/message-remote-args.ts +69 -0
- package/src/cli/message-remote-command.ts +77 -0
- package/src/cli/provider-runtime.ts +69 -1
- package/src/cli/registry.ts +3 -3
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-pause.ts +6 -1
- package/src/codex/account-runtime-state.ts +5 -0
- package/src/codex/account-store.ts +55 -9
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +13 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/main-account-probe.ts +3 -2
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +57 -10
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/auto-review.ts +2 -1
- package/src/codex/catalog/build-entries.ts +17 -7
- package/src/codex/catalog/derive-entry.ts +3 -1
- package/src/codex/catalog/metadata.ts +2 -0
- package/src/codex/catalog/model-visibility.ts +0 -2
- package/src/codex/catalog/ownership.ts +71 -0
- package/src/codex/catalog/parsing.ts +12 -3
- package/src/codex/catalog/retained-sync.ts +2 -1
- package/src/codex/catalog/routed-removal.ts +4 -4
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/low-quota-desktop-notifier.ts +125 -0
- package/src/codex/low-quota-protection.ts +57 -31
- package/src/codex/main-account-cache.ts +77 -3
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/main-account.ts +6 -2
- package/src/codex/native-residue.ts +2 -3
- package/src/codex/quota.ts +26 -2
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/combos/failover.ts +10 -2
- package/src/combos/jev-model-backend.ts +30 -7
- package/src/combos/jev-model-request.ts +20 -0
- package/src/combos/jev-quota-activation.ts +153 -0
- package/src/combos/jev-quota-config.ts +62 -0
- package/src/combos/jev-quota-route.ts +26 -0
- package/src/combos/jev-quota.ts +88 -0
- package/src/combos/jev.ts +25 -12
- package/src/combos/types.ts +27 -0
- package/src/compatibility/index.ts +3 -0
- package/src/compatibility/openai-responses.ts +8 -1
- package/src/compatibility/opengateway-responses.ts +61 -0
- package/src/config/account-auto-pause-revisions.ts +136 -0
- package/src/config/diagnostics.ts +13 -0
- package/src/config/live-reconcile.ts +9 -0
- package/src/config/persist-unlocked.ts +10 -1
- package/src/config/provider-validation.ts +7 -2
- package/src/config/removed-opencode-free.ts +135 -0
- package/src/config/schema/account-auto-pause.ts +14 -0
- package/src/config/schema/config-schema.ts +15 -0
- package/src/config/schema/leaf-validators.ts +7 -0
- package/src/config.ts +10 -3
- package/src/generated/compatibility-version.json +476 -216
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +34 -11
- package/src/lib/account-auto-pause-response.ts +16 -0
- package/src/lib/bun-runtime.ts +18 -1
- package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
- package/src/lib/decision-quota-sink.ts +76 -0
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/state-store-registrations.ts +25 -0
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/link/ssh-argv.ts +3 -0
- package/src/messaging/envelope.ts +6 -3
- package/src/messaging/remote-auth.ts +83 -0
- package/src/messaging/remote-bridge.ts +270 -0
- package/src/messaging/remote-contract.ts +108 -0
- package/src/messaging/remote-enrollment-recovery.ts +59 -0
- package/src/messaging/remote-enrollment.ts +244 -0
- package/src/messaging/remote-envelope.ts +40 -0
- package/src/messaging/remote-files.ts +117 -0
- package/src/messaging/remote-owner.ts +169 -0
- package/src/messaging/remote-ports.ts +56 -0
- package/src/messaging/remote-process.ts +131 -0
- package/src/messaging/remote-replies.ts +98 -0
- package/src/messaging/remote-rpc-admission.ts +32 -0
- package/src/messaging/remote-send.ts +72 -0
- package/src/messaging/remote-store.ts +111 -0
- package/src/messaging/remote-tunnels.ts +65 -0
- package/src/messaging/rpc.ts +13 -1
- package/src/oauth/account-auto-pause-policy.ts +66 -0
- package/src/oauth/account-auto-pause.ts +171 -0
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/account-quota-rank.ts +2 -1
- package/src/oauth/anthropic-account-refusal.ts +6 -0
- package/src/oauth/anthropic-model-family.ts +8 -0
- package/src/oauth/anthropic-model-quota.ts +2 -6
- package/src/oauth/api-key-pool-preserve.ts +37 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/index.ts +5 -35
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/oauth/store.ts +35 -8
- package/src/oauth/types.ts +2 -0
- package/src/plugins/upstream-hooks.ts +14 -1
- package/src/providers/api-key-pause.ts +32 -0
- package/src/providers/api-key-resolve.ts +10 -4
- package/src/providers/api-key-selection.ts +6 -1
- package/src/providers/api-keys.ts +8 -4
- package/src/providers/claude-cli-usage.ts +25 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +21 -13
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +207 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/opencode-zen-rate-limit.ts +6 -72
- package/src/providers/quota/account-cache.ts +15 -1
- package/src/providers/quota/anthropic-account-quota.ts +20 -7
- package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
- package/src/providers/quota/anthropic-decision-windows.ts +47 -0
- package/src/providers/quota/antigravity.ts +45 -8
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +15 -6
- package/src/providers/quota-decision-publication.ts +77 -0
- package/src/providers/quota-decision-snapshot.ts +145 -0
- package/src/providers/quota-routing-cache.ts +4 -1
- package/src/providers/quota-types.ts +6 -1
- package/src/providers/quota.ts +8 -3
- package/src/providers/registry/entries-extended.ts +14 -53
- package/src/providers/registry/model-seeds.ts +1 -1
- package/src/providers/registry.ts +1 -2
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/stale-vision-classification-migration.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/parser.ts +18 -10
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +147 -0
- package/src/routing/compatibility/behavior.ts +4 -1
- package/src/server/auth-cors.ts +7 -0
- package/src/server/background-lifecycle.ts +2 -1
- package/src/server/chat-native.ts +36 -20
- package/src/server/claude-messages.ts +2 -1
- package/src/server/index/optional-listeners.ts +6 -0
- package/src/server/index/serve-options.ts +8 -2
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/management/combo-routes.ts +17 -2
- package/src/server/management/oauth-account-routes.ts +12 -2
- package/src/server/management/provider-completion-test.ts +289 -0
- package/src/server/management/provider-routes.ts +9 -1
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/messages-native.ts +29 -4
- package/src/server/messages-shared.ts +4 -1
- package/src/server/request-log.ts +23 -9
- package/src/server/responses/account-auto-pause-recovery.ts +26 -0
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +79 -30
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-session-recall.ts +25 -0
- package/src/server/responses/combo-stream-preflight.ts +10 -3
- package/src/server/responses/combo-tool-loop-pin.ts +70 -0
- package/src/server/responses/compact.ts +10 -5
- package/src/server/responses/context-overflow.ts +141 -2
- package/src/server/responses/core-auth.ts +20 -0
- package/src/server/responses/core-codex-account.ts +28 -15
- package/src/server/responses/core-combo.ts +34 -5
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/core.ts +12 -9
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +10 -0
- package/src/server/responses/fetch-helpers.ts +65 -14
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/jev-model-invoke.ts +13 -14
- package/src/server/responses/main-hard-lock-routing.ts +33 -8
- package/src/server/responses/native-steering-log.ts +2 -2
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +5 -2
- package/src/server/responses/passthrough-dispatch.ts +42 -45
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/passthrough-observations.ts +38 -0
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-compression.ts +98 -0
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/request-transport.ts +92 -5
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +16 -3
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/startup-action-control.ts +2 -2
- package/src/server/startup-health-cache.ts +2 -2
- package/src/server/windows-tray-control.ts +2 -2
- package/src/service/definition.ts +9 -1
- package/src/service/orchestration.ts +17 -0
- package/src/types/account-auto-pause.ts +15 -0
- package/src/types/config.ts +23 -1
- package/src/types/provider.ts +11 -5
- package/src/types/request.ts +36 -3
- package/src/types.ts +2 -0
- package/src/usage/cost.ts +17 -1
- package/src/usage/expected-prices.ts +3 -1
- package/src/usage/jev-stats.ts +4 -0
- package/src/usage/log.ts +2 -0
- package/src/usage/summary.ts +7 -0
- package/src/web-search/loop.ts +39 -13
- package/src/web-search/progress-stream.ts +4 -1
- package/src/web-search/run-turn-loop.ts +9 -3
- package/src/web-search/sidecar-search-replay.ts +96 -0
- package/gui/dist/assets/App-CHC89EXQ.js +0 -58
- package/gui/dist/assets/App-wT3mXqFz.css +0 -1
- package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
|
@@ -45,8 +45,31 @@ function quotaInfoEntries(modelInfo: Record<string, unknown>): Record<string, un
|
|
|
45
45
|
return entries;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Gemini 4 Argon has its own quota pool. Its names also contain "gemini", so every family
|
|
50
|
+
* decision below checks Argon first; otherwise Argon readings fill or replace the Gem family.
|
|
51
|
+
*/
|
|
52
|
+
const ARGON_TOKEN = /(?:^|[^a-z0-9])argon(?:[^a-z0-9]|$)/i;
|
|
53
|
+
const GEMINI_TOKEN = /(?:^|[^a-z0-9])gemini(?:[^a-z0-9]|$)/i;
|
|
54
|
+
const CLAUDE_FAMILY_TOKEN = /(?:^|[^a-z0-9])(?:claude|3p|gpt|opus|sonnet)(?:[^a-z0-9]|$)/i;
|
|
55
|
+
/** Canonical model of the Argon summary pool; tagging keeps the window out of routing. */
|
|
56
|
+
const ARGON_POOL_MODEL_ID = "gemini-4-argon";
|
|
57
|
+
|
|
58
|
+
type AntigravitySummaryFamily = "argon" | "gem" | "cla";
|
|
59
|
+
|
|
60
|
+
/** A bucket's own id and name decide its family when they name one; the group decides otherwise. */
|
|
61
|
+
function antigravitySummaryBucketFamily(bucket: Record<string, unknown>): AntigravitySummaryFamily | undefined {
|
|
62
|
+
const identity = `${typeof bucket.bucketId === "string" ? bucket.bucketId : ""} ${typeof bucket.displayName === "string" ? bucket.displayName : ""}`;
|
|
63
|
+
if (ARGON_TOKEN.test(identity)) return "argon";
|
|
64
|
+
if (GEMINI_TOKEN.test(identity)) return "gem";
|
|
65
|
+
if (CLAUDE_FAMILY_TOKEN.test(identity)) return "cla";
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
48
69
|
function classifyAntigravityFamily(modelId: string, modelInfo: Record<string, unknown>, quotaInfo: Record<string, unknown>): "Gem" | "Cla" | null {
|
|
49
70
|
const displayName = typeof modelInfo.displayName === "string" ? modelInfo.displayName : "";
|
|
71
|
+
// Argon models keep their exact model rows but never seed the shared Gem family bar.
|
|
72
|
+
if (ARGON_TOKEN.test(`${modelId} ${displayName}`)) return null;
|
|
50
73
|
const tier = typeof quotaInfo.tier === "string" ? quotaInfo.tier : "";
|
|
51
74
|
const haystack = `${modelId} ${displayName} ${tier}`.toLowerCase();
|
|
52
75
|
if (haystack.includes("gemini")) return "Gem";
|
|
@@ -128,9 +151,13 @@ function parseAntigravityQuotaSummary(body: Record<string, unknown> | null): Pro
|
|
|
128
151
|
for (const rawGroup of groups) {
|
|
129
152
|
const group = asRecord(rawGroup);
|
|
130
153
|
if (!group) continue;
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
|
|
154
|
+
const groupDisplayName = typeof group.displayName === "string" ? group.displayName : "";
|
|
155
|
+
const groupName = `${groupDisplayName} ${typeof group.description === "string" ? group.description : ""}`.toLowerCase();
|
|
156
|
+
// A description that merely mentions Argon must not move a whole Gemini group.
|
|
157
|
+
const groupFamily: AntigravitySummaryFamily | undefined = ARGON_TOKEN.test(groupDisplayName) ? "argon"
|
|
158
|
+
: groupName.includes("gemini") ? "gem"
|
|
159
|
+
: groupName.includes("claude") || groupName.includes("3p") || groupName.includes("gpt") ? "cla"
|
|
160
|
+
: undefined;
|
|
134
161
|
|
|
135
162
|
const buckets = Array.isArray(group.buckets) ? (group.buckets as unknown[]) : [];
|
|
136
163
|
for (const rawBucket of buckets) {
|
|
@@ -143,13 +170,19 @@ function parseAntigravityQuotaSummary(body: Record<string, unknown> | null): Pro
|
|
|
143
170
|
|
|
144
171
|
const isWeekly = windowStr.includes("week");
|
|
145
172
|
const is5h = windowStr.includes("5h") || windowStr.includes("five");
|
|
173
|
+
const family = antigravitySummaryBucketFamily(bucket) ?? groupFamily;
|
|
146
174
|
|
|
147
|
-
if (
|
|
175
|
+
if (family === "argon") {
|
|
176
|
+
const label = is5h ? "Argon" : isWeekly ? "Argon (Weekly)" : "";
|
|
177
|
+
if (label && !customWindowsMap.has(label)) {
|
|
178
|
+
customWindowsMap.set(label, { label, modelId: ARGON_POOL_MODEL_ID, percent, ...(resetAt !== undefined ? { resetAt } : {}) });
|
|
179
|
+
}
|
|
180
|
+
} else if (family === "gem") {
|
|
148
181
|
const label = is5h ? "Gem" : isWeekly ? "Gem (Weekly)" : "";
|
|
149
182
|
if (label && !customWindowsMap.has(label)) {
|
|
150
183
|
customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) });
|
|
151
184
|
}
|
|
152
|
-
} else if (
|
|
185
|
+
} else if (family === "cla") {
|
|
153
186
|
const label = is5h ? "Cla" : isWeekly ? "Cla (Weekly)" : "";
|
|
154
187
|
if (label && !customWindowsMap.has(label)) {
|
|
155
188
|
customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) });
|
|
@@ -164,7 +197,7 @@ function parseAntigravityQuotaSummary(body: Record<string, unknown> | null): Pro
|
|
|
164
197
|
}
|
|
165
198
|
}
|
|
166
199
|
|
|
167
|
-
const PREFERRED_ORDER = ["Gem", "Gem (Weekly)", "Cla", "Cla (Weekly)"];
|
|
200
|
+
const PREFERRED_ORDER = ["Gem", "Gem (Weekly)", "Cla", "Cla (Weekly)", "Argon", "Argon (Weekly)"];
|
|
168
201
|
const customWindows = Array.from(customWindowsMap.values()).sort((a, b) => {
|
|
169
202
|
const ia = PREFERRED_ORDER.indexOf(a.label);
|
|
170
203
|
const ib = PREFERRED_ORDER.indexOf(b.label);
|
|
@@ -273,13 +306,16 @@ function antigravityUnavailableFailure(
|
|
|
273
306
|
return fallbackFailure;
|
|
274
307
|
}
|
|
275
308
|
|
|
276
|
-
export async function probeAntigravityUsageQuota(accessToken: string, projectId: string): Promise<AntigravityQuotaProbeResult> {
|
|
309
|
+
export async function probeAntigravityUsageQuota(accessToken: string, projectId: string, signal?: AbortSignal): Promise<AntigravityQuotaProbeResult> {
|
|
310
|
+
// An optional caller signal (account import) cancels every request of this probe, including the
|
|
311
|
+
// 403 retry and the models fallback, alongside the per-request deadline.
|
|
312
|
+
const requestSignal = () => signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
277
313
|
const fetchQuota = (url: string, userAgent = antigravityUserAgent()) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, {
|
|
278
314
|
headers: {
|
|
279
315
|
Accept: "application/json", "Content-Type": "application/json",
|
|
280
316
|
"User-Agent": userAgent, Authorization: `Bearer ${accessToken}`,
|
|
281
317
|
},
|
|
282
|
-
body: JSON.stringify({ project: projectId }), signal:
|
|
318
|
+
body: JSON.stringify({ project: projectId }), signal: requestSignal(),
|
|
283
319
|
}, antigravityOutboundDependencies);
|
|
284
320
|
let summaryFailure: QuotaFailureCode | undefined;
|
|
285
321
|
try {
|
|
@@ -299,6 +335,7 @@ export async function probeAntigravityUsageQuota(accessToken: string, projectId:
|
|
|
299
335
|
// Existing behavior: summary transport/parse failure may recover through the models probe.
|
|
300
336
|
summaryFailure = quotaTransportFailure(error);
|
|
301
337
|
}
|
|
338
|
+
if (signal?.aborted) return unavailableAntigravityQuota(summaryFailure ?? "transport_error");
|
|
302
339
|
try {
|
|
303
340
|
const response = await fetchQuota(ANTIGRAVITY_QUOTA_MODELS_URL);
|
|
304
341
|
if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_MODELS_URL)) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { QuotaFailureCode } from "../quota-types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Closed quota-probe diagnoses derived from an HTTP status class or a thrown dispatch. They carry
|
|
5
|
+
* no upstream text, URL, header or credential: only one of `QUOTA_FAILURE_CODES`.
|
|
6
|
+
*/
|
|
7
|
+
export function httpQuotaFailure(status: number): QuotaFailureCode {
|
|
8
|
+
if (status >= 300 && status < 400) return "redirect_blocked";
|
|
9
|
+
if (status === 401 || status === 403) return "access_denied";
|
|
10
|
+
if (status === 429) return "rate_limited";
|
|
11
|
+
return "upstream_error";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A thrown fetch: the request deadline is a timeout, anything else a transport failure. */
|
|
15
|
+
export function thrownQuotaFailure(error: unknown): QuotaFailureCode {
|
|
16
|
+
const name = error && typeof error === "object" ? (error as { name?: unknown }).name : undefined;
|
|
17
|
+
return name === "TimeoutError" ? "timeout" : "transport_error";
|
|
18
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { decisionQuotaSinkActive, invalidateDecisionKeyQuotas, publishDecisionKeyQuota } from "../../lib/decision-quota-sink";
|
|
2
|
+
import { providerDecisionWindows } from "../quota-decision-publication";
|
|
1
3
|
import { createHash } from "node:crypto";
|
|
2
4
|
import { effectiveCodexAuthAccountId, listCodexAuthAccountsSnapshot } from "../../codex/auth-api";
|
|
3
5
|
import { withoutRetiredCodexQuota, type StoredAccountQuota } from "../../codex/quota";
|
|
@@ -345,6 +347,18 @@ export function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolea
|
|
|
345
347
|
&& (accountReportCurrent.get(value)?.() ?? true);
|
|
346
348
|
}
|
|
347
349
|
|
|
350
|
+
/** Called only by an accepted collector/cache publication, never the JEV read path. */
|
|
351
|
+
export function publishCollectedDecisionQuota(reports: ProviderQuotaReport[], config: OcxConfig): void {
|
|
352
|
+
if (!decisionQuotaSinkActive()) return;
|
|
353
|
+
invalidateDecisionKeyQuotas();
|
|
354
|
+
for (const report of reports) {
|
|
355
|
+
const provider = config.providers[report.provider];
|
|
356
|
+
const evidence = routingEvidence.get(report);
|
|
357
|
+
if (provider && evidence && evidence.binding === providerQuotaRoutingBinding(report.provider, provider))
|
|
358
|
+
publishDecisionKeyQuota(report.provider, provider, providerDecisionWindows(evidence.quota));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
348
362
|
/**
|
|
349
363
|
* The reports this process already holds for the configured providers, for a passive read (#6959):
|
|
350
364
|
* no probe, token refresh, account snapshot or publication into routing evidence. The Home's own
|
|
@@ -8,7 +8,7 @@ import { captureOAuthAccountSelection, credentialGeneration, getAccountCredentia
|
|
|
8
8
|
import { hydrateKiroAccountState, persistKiroAccountState } from "../kiro-account-state-disk";
|
|
9
9
|
import { kiroProbeCurrent, kiroProbeIdentity } from "./kiro-account-probe";
|
|
10
10
|
import { fetchMuseKeyQuotaSnapshot } from "../muse-key-quota";
|
|
11
|
-
import {
|
|
11
|
+
import { probeAnthropicUsageQuota, type AnthropicUsageProbe } from "../claude-cli-usage";
|
|
12
12
|
import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "../xai-transport";
|
|
13
13
|
import {
|
|
14
14
|
commitKiroAccountUsageState,
|
|
@@ -227,7 +227,7 @@ export async function fetchXaiQuota(provider: string, context: { accessToken: st
|
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
const anthropicUsageInflight = new Map<string, Promise<
|
|
230
|
+
const anthropicUsageInflight = new Map<string, Promise<AnthropicUsageProbe>>();
|
|
231
231
|
|
|
232
232
|
/**
|
|
233
233
|
* Anthropic per-credential usage.
|
|
@@ -256,6 +256,15 @@ export async function fetchAnthropicUsageQuotaForInstance(
|
|
|
256
256
|
accessToken: string,
|
|
257
257
|
requireFreshDispatch = false,
|
|
258
258
|
): Promise<ProviderQuota | null> {
|
|
259
|
+
return (await fetchAnthropicUsageProbeForInstance(instance, accessToken, requireFreshDispatch)).quota;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The same shared flight as `fetchAnthropicUsageQuotaForInstance`, keeping the closed failure reason. */
|
|
263
|
+
export async function fetchAnthropicUsageProbeForInstance(
|
|
264
|
+
instance: AnthropicInstanceId,
|
|
265
|
+
accessToken: string,
|
|
266
|
+
requireFreshDispatch = false,
|
|
267
|
+
): Promise<AnthropicUsageProbe> {
|
|
259
268
|
// Recovery evidence must be requested after the claimed cooldown. Joining an older request
|
|
260
269
|
// can return after the 429 while still describing provider state from before that refusal.
|
|
261
270
|
const credentialIdentity = getAccountSet(instance)?.accounts.filter(row => row.credential.access === accessToken)
|
|
@@ -264,7 +273,7 @@ export async function fetchAnthropicUsageQuotaForInstance(
|
|
|
264
273
|
const joinable = requireFreshDispatch ? undefined : anthropicUsageInflight.get(key);
|
|
265
274
|
if (joinable) return joinable;
|
|
266
275
|
|
|
267
|
-
const probe =
|
|
276
|
+
const probe = probeAnthropicUsageQuota(accessToken).finally(() => {
|
|
268
277
|
if (anthropicUsageInflight.get(key) === probe) anthropicUsageInflight.delete(key);
|
|
269
278
|
});
|
|
270
279
|
anthropicUsageInflight.set(key, probe);
|
|
@@ -300,7 +309,7 @@ export async function fetchAnthropicQuota(provider: string, config: OcxConfig =
|
|
|
300
309
|
if (!initialRow || !currentRow || initialRow.loginId !== currentRow.loginId || initialRow.addedAt !== currentRow.addedAt
|
|
301
310
|
|| epoch !== captureProviderAccountQuotaEpoch(instance) || recovery.anthropicAccountIncarnation(probedAccountId) !== incarnation) return null;
|
|
302
311
|
let quota: ProviderQuota | null;
|
|
303
|
-
let anthropicCurrent: (() => boolean) | undefined;
|
|
312
|
+
let anthropicCurrent: (() => boolean) | undefined; let anthropicPublish: (() => void) | undefined;
|
|
304
313
|
try {
|
|
305
314
|
const result = await recovery.probeAnthropicQuotaWithRecovery(probedAccountId, accessToken,
|
|
306
315
|
fresh => {
|
|
@@ -311,7 +320,7 @@ export async function fetchAnthropicQuota(provider: string, config: OcxConfig =
|
|
|
311
320
|
() => targetCurrent() && mayCommitAccountQuotaKey(probedAccountKey, writerGeneration));
|
|
312
321
|
if (result && !result.isCurrent()) return null;
|
|
313
322
|
quota = result?.quota ?? null;
|
|
314
|
-
anthropicCurrent = result?.isCurrent;
|
|
323
|
+
anthropicCurrent = result?.isCurrent; anthropicPublish = result?.publishDecisionQuota;
|
|
315
324
|
} catch (error) {
|
|
316
325
|
if (error instanceof AnthropicQuotaProbeOwnershipError) return null;
|
|
317
326
|
throw error;
|
|
@@ -322,7 +331,7 @@ export async function fetchAnthropicQuota(provider: string, config: OcxConfig =
|
|
|
322
331
|
if (probedAccountId && probedAccountKey) {
|
|
323
332
|
const stillOwnsToken = getAccountCredential(instance, probedAccountId)?.access === accessToken;
|
|
324
333
|
if (stillOwnsToken && anthropicCurrent?.() && mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) {
|
|
325
|
-
accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota, isCurrent: anthropicCurrent });
|
|
334
|
+
accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota, isCurrent: anthropicCurrent }); anthropicPublish?.();
|
|
326
335
|
}
|
|
327
336
|
}
|
|
328
337
|
const quotaReport = report(provider, "anthropic:oauth-usage", quota);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Producer projection: only measured subscription windows reach advisory snapshots. */
|
|
2
|
+
import type { ProviderQuota } from "./quota-types";
|
|
3
|
+
import type { StoredAccountQuota } from "../codex/quota-types";
|
|
4
|
+
import { resetAtToMs } from "../codex/quota-types";
|
|
5
|
+
import type { DecisionQuotaWindow } from "./quota-decision-snapshot";
|
|
6
|
+
import { decisionQuotaSinkActive, publishDecisionAccountQuota } from "../lib/decision-quota-sink";
|
|
7
|
+
const rawWindows = new WeakMap<ProviderQuota, readonly DecisionQuotaWindow[]>();
|
|
8
|
+
/** Producer retains raw advisory projection separately from unchanged display/hard policy. */
|
|
9
|
+
export function bindRawDecisionWindows(quota: ProviderQuota, windows: readonly DecisionQuotaWindow[]): void { if (decisionQuotaSinkActive()) rawWindows.set(quota, windows); }
|
|
10
|
+
/**
|
|
11
|
+
* Raw windows a binding producer attached to this observation, or undefined. Binding producers
|
|
12
|
+
* (Anthropic usage/header parsers) bind only while advisory evidence is active, so an observation
|
|
13
|
+
* parsed while inactive has none and must stay unknown rather than fall back to display fields,
|
|
14
|
+
* which drop invalid resets and clamp percentages.
|
|
15
|
+
*/
|
|
16
|
+
export function boundDecisionWindows(quota: ProviderQuota): DecisionQuotaWindow[] | undefined {
|
|
17
|
+
const raw = rawWindows.get(quota);
|
|
18
|
+
return raw ? raw.map(row => ({ ...row })) : undefined;
|
|
19
|
+
}
|
|
20
|
+
/** Project a provider quota report onto decision windows, preferring raw windows bound by the producer over the display fields. */
|
|
21
|
+
export function providerDecisionWindows(quota: ProviderQuota): DecisionQuotaWindow[] {
|
|
22
|
+
const raw = rawWindows.get(quota);
|
|
23
|
+
if (raw) return raw.map(row => ({ ...row }));
|
|
24
|
+
const windows: DecisionQuotaWindow[] = [];
|
|
25
|
+
for (const [window, percent, reset] of [
|
|
26
|
+
["5h", "fiveHourPercent", "fiveHourResetAt"], ["weekly", "weeklyPercent", "weeklyResetAt"], ["monthly", "monthlyPercent", "monthlyResetAt"],
|
|
27
|
+
] as const) {
|
|
28
|
+
if (quota[percent] !== undefined) windows.push({ window, percent: quota[percent]!, observedAt: quota.updatedAt, ...(quota[reset] !== undefined ? { resetAt: quota[reset] } : {}) });
|
|
29
|
+
}
|
|
30
|
+
for (const row of Array.isArray(quota.customWindows) ? quota.customWindows : []) {
|
|
31
|
+
const window = typeof row?.label === "string" ? row.label.trim().toLowerCase() : "";
|
|
32
|
+
if (row?.scope === "model" && (window === "fable" || window === "opus" || window === "sonnet")) {
|
|
33
|
+
windows.push({ window, percent: row.percent, observedAt: row.passiveObservedAt ?? quota.updatedAt, ...(row.resetAt !== undefined ? { resetAt: row.resetAt } : {}) });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return windows;
|
|
37
|
+
}
|
|
38
|
+
/** Publish decision windows for one Anthropic pool account, bound to its credential generation; a null quota publishes no windows. */
|
|
39
|
+
export function publishAnthropicDecisionQuota(id: string, generation: string, quota: ProviderQuota | null, partial = false): void {
|
|
40
|
+
if (!decisionQuotaSinkActive()) return;
|
|
41
|
+
// A non-null observation without a raw binding was parsed while inactive: unknown, never display fallback.
|
|
42
|
+
const windows = quota ? boundDecisionWindows(quota) : [];
|
|
43
|
+
if (windows) publishDecisionAccountQuota("anthropic", id, generation, windows, partial);
|
|
44
|
+
}
|
|
45
|
+
type CodexObservation = Omit<StoredAccountQuota, "updatedAt">;
|
|
46
|
+
type CodexWindow = "short" | "weekly" | "monthly";
|
|
47
|
+
const rawCodexWindows = new WeakMap<CodexObservation, readonly DecisionQuotaWindow[]>();
|
|
48
|
+
/** Keep invalid resets and the producer clock apart from compatibility display/policy parsing. */
|
|
49
|
+
export function bindRawCodexDecisionResets(raw: CodexObservation, resets: Record<CodexWindow, unknown>, observedAt: number): void {
|
|
50
|
+
if (!decisionQuotaSinkActive()) return;
|
|
51
|
+
const windows = codexDecisionWindows(raw, observedAt).map(row => {
|
|
52
|
+
const value = resets[row.window === "5h" ? "short" : row.window as CodexWindow];
|
|
53
|
+
if (value === undefined || value === null) return row;
|
|
54
|
+
const numeric = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
|
|
55
|
+
return { ...row, resetAt: Number.isFinite(numeric) && numeric >= 0 ? resetAtToMs(numeric) : NaN };
|
|
56
|
+
});
|
|
57
|
+
rawCodexWindows.set(raw, windows);
|
|
58
|
+
}
|
|
59
|
+
/** Build decision windows from a raw Codex observation, stamping each with the producer clock rather than a merge-time clock. */
|
|
60
|
+
function codexDecisionWindows(raw: CodexObservation, observedAt: number): DecisionQuotaWindow[] {
|
|
61
|
+
const windows: DecisionQuotaWindow[] = [];
|
|
62
|
+
for (const kind of ["short", "weekly", "monthly"] as const) {
|
|
63
|
+
const percent = raw[`${kind}Percent`];
|
|
64
|
+
const reset = raw[`${kind}ResetAt`];
|
|
65
|
+
if (percent !== undefined) windows.push({ window: kind === "short" ? "5h" : kind, percent, observedAt,
|
|
66
|
+
...(reset !== undefined ? { resetAt: resetAtToMs(reset) } : {}) });
|
|
67
|
+
}
|
|
68
|
+
return windows;
|
|
69
|
+
}
|
|
70
|
+
/** Publish decision windows for a Codex or main account from the raw observation, never from the display merge, so carried windows cannot acquire a fresh clock. */
|
|
71
|
+
export function publishCodexDecisionQuota(id: string, generation: number, raw: CodexObservation, observedAt: number, provider = "codex", partial = true): void {
|
|
72
|
+
if (!decisionQuotaSinkActive()) return;
|
|
73
|
+
// RAW observation, never the display merge: carried windows cannot acquire a fresh clock.
|
|
74
|
+
// Header/WHAM parsers bind reset validity only while active; an unbound observation stays unknown.
|
|
75
|
+
const windows = rawCodexWindows.get(raw);
|
|
76
|
+
if (windows) publishDecisionAccountQuota(provider, id, generation, windows, partial);
|
|
77
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** Loaded-only advisory evidence. No auth, disk, keychain, selectors, timers or probes. Imported only by src/combos/jev-quota-activation.ts; core producers reach it through src/lib/decision-quota-sink.ts while activation is registered. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { getConfigDir } from "../config/paths";
|
|
4
|
+
import { captureConfigGeneration } from "../lib/state-store-sweeper";
|
|
5
|
+
import type { OcxProviderConfig } from "../types";
|
|
6
|
+
|
|
7
|
+
export interface DecisionQuotaWindow {
|
|
8
|
+
window: "5h" | "weekly" | "monthly" | "fable" | "opus" | "sonnet";
|
|
9
|
+
percent: number;
|
|
10
|
+
observedAt: number;
|
|
11
|
+
resetAt?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface DecisionQuotaAccount {
|
|
14
|
+
id: string;
|
|
15
|
+
generation: string | number;
|
|
16
|
+
usable: boolean;
|
|
17
|
+
}
|
|
18
|
+
type Evidence = { generation: string | number; windows: readonly DecisionQuotaWindow[] };
|
|
19
|
+
type Roster = { root: string; accounts: readonly DecisionQuotaAccount[] };
|
|
20
|
+
const rosters = new Map<string, Roster>();
|
|
21
|
+
const accounts = new Map<string, Evidence>();
|
|
22
|
+
const unusable = new Map<string, string | number | true>();
|
|
23
|
+
const POOL_PROVIDERS = new Set(["anthropic", "codex", "codex-main"]);
|
|
24
|
+
const MAX_ROWS = 1024;
|
|
25
|
+
const MAX_WINDOWS = 64;
|
|
26
|
+
let keyEpoch = 0;
|
|
27
|
+
let writesForTests = 0;
|
|
28
|
+
/** Test-only: count of write entry calls, so signals-off tests can assert that nothing reached the snapshot. */
|
|
29
|
+
export function decisionQuotaSnapshotWriteCountForTests(): number { return writesForTests; }
|
|
30
|
+
const credentialVersions = new Map<string, string>();
|
|
31
|
+
/** Credential owner calls this after its normal read; JEV never invokes that owner. */
|
|
32
|
+
export function observeDecisionKeyCredential(reference: string, resolved: string | undefined): void {
|
|
33
|
+
writesForTests++;
|
|
34
|
+
const version = createHash("sha256").update(resolved ?? "").digest("hex");
|
|
35
|
+
const referenceId = createHash("sha256").update(reference).digest("hex");
|
|
36
|
+
const previous = credentialVersions.get(referenceId);
|
|
37
|
+
if (previous !== undefined && previous !== version) invalidateDecisionKeyQuotas();
|
|
38
|
+
if (credentialVersions.size >= 256 && !credentialVersions.has(referenceId)) {
|
|
39
|
+
credentialVersions.delete(credentialVersions.keys().next().value!);
|
|
40
|
+
invalidateDecisionKeyQuotas();
|
|
41
|
+
}
|
|
42
|
+
credentialVersions.set(referenceId, version);
|
|
43
|
+
}
|
|
44
|
+
const keys = new Map<string, { root: string; epoch: number; configGeneration: number; policy: string; windows: readonly DecisionQuotaWindow[] }>();
|
|
45
|
+
/** Compose the per-provider, per-account map key. */
|
|
46
|
+
const accountKey = (provider: string, id: string) => `${provider}\0${id}`;
|
|
47
|
+
// Private policy digest, never returned or serialized. Resolving the configured credential is
|
|
48
|
+
// the collector's responsibility; request reads only this already-published generation.
|
|
49
|
+
/** Digest the provider settings a key-based evidence row was published under; it is never returned or serialized. */
|
|
50
|
+
function keyPolicy(provider: OcxProviderConfig): string {
|
|
51
|
+
return createHash("sha256").update(JSON.stringify([
|
|
52
|
+
provider.adapter, provider.baseUrl, provider.authMode, provider.apiKey, provider.apiKeyPool, provider.headers,
|
|
53
|
+
provider.apiKey && credentialVersions.get(createHash("sha256").update(provider.apiKey).digest("hex")),
|
|
54
|
+
])).digest("hex");
|
|
55
|
+
}
|
|
56
|
+
/** Drop all key-based decision evidence and advance the epoch so earlier publications cannot be read. */
|
|
57
|
+
export function invalidateDecisionKeyQuotas(): void { keyEpoch++; keys.clear(); }
|
|
58
|
+
/** Publish decision windows for a single-key provider; pooled, disabled or non-key providers are never published, and the table is bounded. */
|
|
59
|
+
export function publishDecisionKeyQuota(name: string, provider: OcxProviderConfig, windows: readonly DecisionQuotaWindow[]): void {
|
|
60
|
+
writesForTests++;
|
|
61
|
+
if (name.length > 1024 || provider.disabled || (provider.authMode ?? "key") !== "key" || (provider.apiKeyPool?.length ?? 0) > 1) return;
|
|
62
|
+
if (keys.size >= 256 && !keys.has(name)) keys.delete(keys.keys().next().value!);
|
|
63
|
+
keys.set(name, { root: getConfigDir(), epoch: keyEpoch, configGeneration: captureConfigGeneration(), policy: keyPolicy(provider), windows: copyWindows(windows) });
|
|
64
|
+
}
|
|
65
|
+
/** Read key-based evidence only when the config root, epoch, generation and provider policy still match what was published. */
|
|
66
|
+
export function readLoadedDecisionKeyQuota(name: string, provider: OcxProviderConfig): readonly DecisionQuotaWindow[] | undefined {
|
|
67
|
+
const row = keys.get(name);
|
|
68
|
+
if (!row || provider.disabled || (provider.authMode ?? "key") !== "key" || (provider.apiKeyPool?.length ?? 0) > 1
|
|
69
|
+
|| row.root !== getConfigDir() || row.epoch !== keyEpoch || row.configGeneration !== captureConfigGeneration()
|
|
70
|
+
|| row.policy !== keyPolicy(provider)) return undefined;
|
|
71
|
+
return row.windows.map(row => ({ ...row }));
|
|
72
|
+
}
|
|
73
|
+
/** Replace a pool provider's roster; an oversized pool makes the whole pool unknown rather than hiding usable rows. */
|
|
74
|
+
export function publishDecisionQuotaRoster(provider: string, rows: readonly DecisionQuotaAccount[]): void {
|
|
75
|
+
writesForTests++;
|
|
76
|
+
if (!POOL_PROVIDERS.has(provider)) return;
|
|
77
|
+
const old = rosters.get(provider);
|
|
78
|
+
if (old?.root !== getConfigDir()) {
|
|
79
|
+
clearDecisionAccountQuotas(provider);
|
|
80
|
+
for (const key of unusable.keys()) if (key.startsWith(`${provider}\0`)) unusable.delete(key);
|
|
81
|
+
}
|
|
82
|
+
// Overflow or oversized identities make the WHOLE pool unknown; never hide a usable row.
|
|
83
|
+
if (rows.length > MAX_ROWS || rows.some(row => row.id.length > 1024
|
|
84
|
+
|| typeof row.generation === "string" && row.generation.length > 128)) {
|
|
85
|
+
clearDecisionAccountQuotas(provider);
|
|
86
|
+
rosters.delete(provider);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const live = new Map(rows.map(row => [row.id, row]));
|
|
90
|
+
for (const row of old?.accounts ?? []) {
|
|
91
|
+
if (live.get(row.id)?.generation !== row.generation) {
|
|
92
|
+
const key = accountKey(provider, row.id);
|
|
93
|
+
accounts.delete(key);
|
|
94
|
+
if (!live.has(row.id) || unusable.get(key) !== true) unusable.delete(key);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
rosters.set(provider, { root: getConfigDir(), accounts: rows.map(row => ({ id: row.id, generation: row.generation, usable: row.usable })) });
|
|
98
|
+
}
|
|
99
|
+
/** Withdraw a pool provider's roster and evidence so readers see it as unknown rather than stale or empty. */
|
|
100
|
+
export function withdrawDecisionQuotaRoster(provider: string): void {
|
|
101
|
+
clearDecisionAccountQuotas(provider);
|
|
102
|
+
rosters.delete(provider);
|
|
103
|
+
for (const key of unusable.keys()) if (key.startsWith(`${provider}\0`)) unusable.delete(key);
|
|
104
|
+
}
|
|
105
|
+
/** Publish windows for a roster account at the matching credential generation; a partial observation retains other windows of the same generation. */
|
|
106
|
+
export function publishDecisionAccountQuota(provider: string, id: string, generation: string | number, windows: readonly DecisionQuotaWindow[], partial = false): void {
|
|
107
|
+
writesForTests++;
|
|
108
|
+
const roster = rosters.get(provider);
|
|
109
|
+
if (roster?.root !== getConfigDir() || !roster.accounts.some(row => row.id === id && row.generation === generation)) return;
|
|
110
|
+
const key = accountKey(provider, id);
|
|
111
|
+
const previous = accounts.get(key);
|
|
112
|
+
const retained = partial && previous?.generation === generation ? previous.windows.filter(row => !windows.some(next => row.window === next.window)) : [];
|
|
113
|
+
accounts.set(key, { generation, windows: copyWindows([...retained, ...windows]) });
|
|
114
|
+
}
|
|
115
|
+
/** Read a pool's already-loaded roster with generation-matched windows; undefined when nothing is loaded for the current config root. */
|
|
116
|
+
export function readLoadedDecisionQuotaPool(provider: string): readonly { id: string; usable: boolean; windows?: readonly DecisionQuotaWindow[] }[] | undefined {
|
|
117
|
+
const roster = rosters.get(provider);
|
|
118
|
+
if (roster?.root !== getConfigDir()) return undefined;
|
|
119
|
+
return roster.accounts.map(row => {
|
|
120
|
+
const evidence = accounts.get(accountKey(provider, row.id));
|
|
121
|
+
const quarantine = unusable.get(accountKey(provider, row.id));
|
|
122
|
+
return { id: row.id, usable: row.usable && quarantine !== true && quarantine !== row.generation, ...(evidence?.generation === row.generation ? { windows: evidence.windows.map(window => ({ ...window })) } : {}) };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/** Mark a roster account usable or unusable, optionally scoped to one credential generation so an unrelated generation is left intact. */
|
|
126
|
+
export function setDecisionAccountUsable(provider: string, id: string, usable: boolean, generation?: string | number): void {
|
|
127
|
+
writesForTests++;
|
|
128
|
+
const roster = rosters.get(provider);
|
|
129
|
+
if (roster?.root !== getConfigDir()) return;
|
|
130
|
+
const row = roster.accounts.find(row => row.id === id && (generation === undefined || generation === row.generation));
|
|
131
|
+
if (!row) return;
|
|
132
|
+
const key = accountKey(provider, id);
|
|
133
|
+
if (usable) {
|
|
134
|
+
if (generation === undefined || unusable.get(key) !== true) unusable.delete(key);
|
|
135
|
+
} else if (generation === undefined || unusable.get(key) !== true) unusable.set(key, generation ?? true);
|
|
136
|
+
}
|
|
137
|
+
/** Drop published account windows for a provider or one account, or for every provider when none is given. */
|
|
138
|
+
export function clearDecisionAccountQuotas(provider?: string, id?: string): void {
|
|
139
|
+
for (const key of accounts.keys()) if (!provider || key.startsWith(`${provider}\0`) && (!id || key === accountKey(provider, id))) accounts.delete(key);
|
|
140
|
+
}
|
|
141
|
+
/** Copy only known window kinds into the bounded evidence table, so callers cannot retain a mutable reference. */
|
|
142
|
+
function copyWindows(windows: readonly DecisionQuotaWindow[]): DecisionQuotaWindow[] {
|
|
143
|
+
if (windows.length > MAX_WINDOWS) return [];
|
|
144
|
+
return windows.filter(row => ["5h", "weekly", "monthly", "fable", "opus", "sonnet"].includes(row.window)).map(row => ({ window: row.window, percent: row.percent, observedAt: row.observedAt, ...(row.resetAt !== undefined ? { resetAt: row.resetAt } : {}) }));
|
|
145
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { invalidateDecisionKeyQuotas } from "../lib/decision-quota-sink";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import type { OcxProviderConfig } from "../types";
|
|
3
4
|
import type { ProviderQuota, ProviderQuotaReport } from "./quota";
|
|
@@ -28,7 +29,7 @@ export function providerQuotaRoutingBinding(
|
|
|
28
29
|
// A key probe must not constrain that later account selection.
|
|
29
30
|
const entry = getProviderRegistryEntry(name);
|
|
30
31
|
if (entry && (entry.authKind === "oauth" || entry.authKind === "forward")
|
|
31
|
-
&& !providerUsesKeyAuthOverride(entry, provider
|
|
32
|
+
&& !providerUsesKeyAuthOverride(entry, provider)) return null;
|
|
32
33
|
// Static auth headers can replace or combine with the probed API-key header.
|
|
33
34
|
// Its semantics belong to the adapter, so it is not provider-wide quota evidence.
|
|
34
35
|
if (Object.keys(provider.headers ?? {}).some(header =>
|
|
@@ -38,7 +39,9 @@ export function providerQuotaRoutingBinding(
|
|
|
38
39
|
])).digest("hex");
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/** Clear the routing quota cache together with key-based decision evidence, so one cannot outlive the other. */
|
|
41
43
|
export function clearCachedProviderQuotas(): void {
|
|
44
|
+
invalidateDecisionKeyQuotas();
|
|
42
45
|
quotaCache.clear();
|
|
43
46
|
}
|
|
44
47
|
|
|
@@ -19,7 +19,12 @@ export interface ProviderQuotaWindow {
|
|
|
19
19
|
label: string;
|
|
20
20
|
percent: number;
|
|
21
21
|
resetAt?: number;
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Model this window is attributed to: the exact CCA wire model of a fallback measurement, or
|
|
24
|
+
* the canonical model of a quota-summary bucket dedicated to one model (Antigravity's Gemini 4
|
|
25
|
+
* Argon pool). Display evidence only, never eligibility: routing, exhaustion, reset ordering
|
|
26
|
+
* and reset notifications ignore every window that carries it.
|
|
27
|
+
*/
|
|
23
28
|
modelId?: string;
|
|
24
29
|
/**
|
|
25
30
|
* Set only when the PRODUCER proved this window covers one model family, structurally rather
|
package/src/providers/quota.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { listCodexAuthAccountsSnapshot } from "../codex/auth-api";
|
|
2
2
|
import { isAnthropicInstanceId, configuredAnthropicInstance } from "./anthropic-instance";
|
|
3
3
|
import { fetchAnthropicAccountQuota } from "./quota/anthropic-account-quota";
|
|
4
|
+
import { thrownQuotaFailure } from "./quota/failure-codes";
|
|
4
5
|
import { resolveEnvValue } from "../config";
|
|
5
6
|
import { getAccountCredential, getAccountSet } from "../oauth/store";
|
|
6
7
|
import { apiKeyPoolEntryId } from "./api-keys";
|
|
@@ -31,7 +32,7 @@ import {
|
|
|
31
32
|
isProviderQuotaReportCurrent,
|
|
32
33
|
LAST_GOOD_MAX_AGE_MS,
|
|
33
34
|
providerQuotaBeforePublishForTests,
|
|
34
|
-
routingEvidence,
|
|
35
|
+
routingEvidence, publishCollectedDecisionQuota,
|
|
35
36
|
setProviderQuotaReportCache,
|
|
36
37
|
TERMINAL_QUOTA_FAILURE,
|
|
37
38
|
type CodexAuthAccountsSnapshotPromise,
|
|
@@ -228,6 +229,7 @@ export function flushProviderQuotaObservationsForTests(): Promise<void> {
|
|
|
228
229
|
return pendingProviderObservation;
|
|
229
230
|
}
|
|
230
231
|
|
|
232
|
+
/** Collect provider quota reports, using the cache unless refresh is forced, and publish the secret-free decision evidence from the collected reports. */
|
|
231
233
|
export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh = false): Promise<ProviderQuotaResponse> {
|
|
232
234
|
// A passive read (a Remote Link Child's, #6959) shows the held reports and starts nothing.
|
|
233
235
|
if (isPassiveRead()) return heldProviderQuotaReports(Object.keys(config.providers));
|
|
@@ -328,7 +330,7 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh
|
|
|
328
330
|
) {
|
|
329
331
|
const reports = response.reports.filter(item => mayCommitProviderQuotaKey(item.provider, writerGeneration));
|
|
330
332
|
setProviderQuotaReportCache({ key, ts: Date.now(), response: { ...response, reports } });
|
|
331
|
-
replaceCachedProviderQuotas(reports, routingEvidence);
|
|
333
|
+
replaceCachedProviderQuotas(reports, routingEvidence); publishCollectedDecisionQuota(reports, config);
|
|
332
334
|
notifyProviderQuotaSnapshot(reports, config);
|
|
333
335
|
}
|
|
334
336
|
return response;
|
|
@@ -514,7 +516,10 @@ async function fetchAccountQuota(
|
|
|
514
516
|
}
|
|
515
517
|
return entry;
|
|
516
518
|
} catch (error) {
|
|
517
|
-
|
|
519
|
+
// The probe itself reports HTTP and transport failures as results; what throws here is the
|
|
520
|
+
// credential side (missing/paused account, failed refresh, no project). A refresh that hit
|
|
521
|
+
// its deadline is a timeout to retry, not an account the operator must re-sign.
|
|
522
|
+
if (provider === "google-antigravity") quotaFailure = thrownQuotaFailure(error) === "timeout" ? "timeout" : "account_unavailable";
|
|
518
523
|
return unavailable();
|
|
519
524
|
}
|
|
520
525
|
})().finally(() => {
|