@bitkyc08/opencodex 2.88.0 → 2.89.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-BnRSujOq.js +58 -0
- package/gui/dist/assets/App-dnkJ85te.css +1 -0
- package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-7z1COnmz.js} +1 -1
- package/gui/dist/assets/{index-B5bsvxor.js → index-uchD-ngf.js} +13 -13
- package/gui/dist/assets/{quota-summary-D8y2wTsB.js → quota-summary-BY_10IEz.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- 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/coding-agent/protocol.ts +6 -0
- package/src/adapters/google-http.ts +3 -2
- package/src/adapters/google-vertex-endpoint.ts +67 -0
- package/src/adapters/google.ts +35 -29
- package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
- package/src/adapters/openai-responses/passthrough.ts +17 -12
- package/src/adapters/openai-responses/reasoning.ts +68 -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/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/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-pause.ts +6 -1
- package/src/codex/account-runtime-state.ts +5 -0
- package/src/codex/account-store.ts +30 -3
- package/src/codex/auth-api/account-list.ts +3 -0
- package/src/codex/auth-api/main-account-probe.ts +3 -2
- package/src/codex/auth-context.ts +9 -4
- 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/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.ts +6 -2
- package/src/codex/native-residue.ts +2 -3
- package/src/codex/quota.ts +26 -2
- package/src/combos/failover.ts +2 -1
- 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/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 +344 -144
- package/src/images/loop.ts +32 -9
- 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 +68 -0
- package/src/lib/decision-quota-sink.ts +76 -0
- package/src/lib/state-store-registrations.ts +25 -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-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/index.ts +5 -35
- 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 +5 -0
- package/src/providers/key-failover.ts +11 -11
- package/src/providers/model-rename-fields.ts +1 -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 +2 -1
- 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 +39 -6
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- 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 +3 -0
- package/src/providers/quota-types.ts +6 -1
- package/src/providers/quota.ts +3 -2
- package/src/providers/registry/entries-extended.ts +8 -52
- package/src/providers/registry/model-seeds.ts +1 -1
- package/src/providers/registry.ts +1 -2
- package/src/providers/stale-vision-classification-migration.ts +1 -1
- package/src/responses/parser.ts +18 -10
- package/src/responses/web-search-replay.ts +138 -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 +28 -13
- package/src/server/claude-messages.ts +2 -1
- package/src/server/index/optional-listeners.ts +6 -0
- package/src/server/management/combo-routes.ts +17 -2
- package/src/server/management/oauth-account-routes.ts +10 -1
- 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 +18 -4
- package/src/server/messages-shared.ts +4 -1
- package/src/server/request-log.ts +7 -2
- package/src/server/responses/account-auto-pause-recovery.ts +26 -0
- package/src/server/responses/adapter-dispatch.ts +77 -28
- package/src/server/responses/combo-session-recall.ts +25 -0
- package/src/server/responses/combo-stream-preflight.ts +3 -1
- package/src/server/responses/combo-tool-loop-pin.ts +70 -0
- package/src/server/responses/compact.ts +8 -5
- package/src/server/responses/context-overflow.ts +141 -2
- package/src/server/responses/core-codex-account.ts +7 -1
- package/src/server/responses/core-combo.ts +31 -5
- package/src/server/responses/core.ts +12 -9
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +35 -4
- package/src/server/responses/jev-model-invoke.ts +13 -14
- package/src/server/responses/native-steering-log.ts +2 -2
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +30 -33
- package/src/server/responses/passthrough-observations.ts +38 -0
- package/src/server/responses/request-compression.ts +98 -0
- package/src/server/responses/request-transport.ts +32 -3
- 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/types/account-auto-pause.ts +15 -0
- package/src/types/config.ts +23 -1
- package/src/types/provider.ts +9 -5
- package/src/types/request.ts +20 -2
- package/src/types.ts +1 -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 +37 -11
- 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
|
@@ -21,6 +21,8 @@ export interface ProviderApiKeyInfo extends AccountQuotaFields {
|
|
|
21
21
|
/** First/last 4 chars only; env references (`${VAR}`) are shown verbatim (not secrets). */
|
|
22
22
|
masked: string;
|
|
23
23
|
active: boolean;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
pauseDetails?: import("../types/account-auto-pause").AccountPauseDetails;
|
|
24
26
|
addedAt?: number;
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -217,7 +219,7 @@ export function listProviderApiKeys(config: OcxConfig, name: string): { activeId
|
|
|
217
219
|
const pool = provider.apiKeyPool?.length
|
|
218
220
|
? provider.apiKeyPool
|
|
219
221
|
: provider.apiKey ? [{ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }] : [];
|
|
220
|
-
const activeId =
|
|
222
|
+
const activeId = pool.find(entry => entry.key === provider.apiKey)?.id ?? null;
|
|
221
223
|
return {
|
|
222
224
|
activeId,
|
|
223
225
|
keys: pool.map(entry => ({
|
|
@@ -225,6 +227,8 @@ export function listProviderApiKeys(config: OcxConfig, name: string): { activeId
|
|
|
225
227
|
...(entry.label ? { label: entry.label } : {}),
|
|
226
228
|
masked: maskApiKey(entry.key),
|
|
227
229
|
active: entry.id === activeId,
|
|
230
|
+
...(entry.disabled ? { disabled: true } : {}),
|
|
231
|
+
...(entry.pauseDetails ? { pauseDetails: entry.pauseDetails } : {}),
|
|
228
232
|
...(entry.addedAt !== undefined ? { addedAt: entry.addedAt } : {}),
|
|
229
233
|
})),
|
|
230
234
|
};
|
|
@@ -300,7 +304,7 @@ export function addProviderApiKey(config: OcxConfig, name: string, key: string,
|
|
|
300
304
|
} else {
|
|
301
305
|
pool.push({ id, key: trimmed, ...(label?.trim() ? { label: label.trim() } : {}), addedAt: Date.now() });
|
|
302
306
|
}
|
|
303
|
-
fresh.apiKey = trimmed;
|
|
307
|
+
if (!existing?.disabled) fresh.apiKey = trimmed;
|
|
304
308
|
return { changed: true, selectionChanged: true, value: id };
|
|
305
309
|
});
|
|
306
310
|
if (committed.status !== "committed") return { error: "provider selection unavailable" };
|
|
@@ -313,7 +317,7 @@ export function setActiveProviderApiKey(config: OcxConfig, name: string, id: str
|
|
|
313
317
|
const entry = provider.apiKeyPool?.find(e => e.id === id)
|
|
314
318
|
?? (!provider.apiKeyPool?.length && provider.apiKey && apiKeyPoolEntryId(provider.apiKey) === id
|
|
315
319
|
? { id, key: provider.apiKey } : undefined);
|
|
316
|
-
if (!entry) return { changed: false, value: false };
|
|
320
|
+
if (!entry || entry.disabled) return { changed: false, value: false };
|
|
317
321
|
ensurePool(provider);
|
|
318
322
|
provider.apiKey = entry.key;
|
|
319
323
|
return { changed: true, selectionChanged: true, value: true };
|
|
@@ -344,7 +348,7 @@ export function removeProviderApiKey(config: OcxConfig, name: string, id: string
|
|
|
344
348
|
removed = entry.key;
|
|
345
349
|
provider.apiKeyPool = pool.filter(e => e.id !== id);
|
|
346
350
|
if (provider.apiKey === entry.key) {
|
|
347
|
-
const next = provider.apiKeyPool
|
|
351
|
+
const next = provider.apiKeyPool.find(candidate => !candidate.disabled);
|
|
348
352
|
if (next) provider.apiKey = next.key;
|
|
349
353
|
else delete provider.apiKey;
|
|
350
354
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { markAnthropicFamilyEnumeration } from "./quota/anthropic-family-headers";
|
|
2
|
+
import { rawAnthropicUsageWindows } from "./quota/anthropic-decision-windows";
|
|
3
|
+
import { bindRawDecisionWindows } from "./quota-decision-publication";
|
|
4
|
+
import { decisionQuotaSinkActive } from "../lib/decision-quota-sink";
|
|
2
5
|
import { CLAUDE_CLI_USER_AGENT } from "./claude-cli-identity";
|
|
3
6
|
import { asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TIMEOUT_MS } from "./quota-wire";
|
|
4
7
|
import type { ProviderQuota, ProviderQuotaWindow } from "./quota-types";
|
|
@@ -41,6 +44,7 @@ function parseClaudeLimit(value: unknown): ProviderQuotaWindow | null {
|
|
|
41
44
|
return { label, scope: "model", percent, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
/** Read the Anthropic OAuth usage endpoint for an access token and return the parsed quota, or null on a non-OK or malformed response. */
|
|
44
48
|
export async function readAnthropicUsageQuota(accessToken: string): Promise<ProviderQuota | null> {
|
|
45
49
|
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
46
50
|
headers: {
|
|
@@ -87,6 +91,7 @@ export async function readAnthropicUsageQuota(accessToken: string): Promise<Prov
|
|
|
87
91
|
...(customWindows.length > 0 ? { customWindows } : {}),
|
|
88
92
|
updatedAt: Date.now(),
|
|
89
93
|
};
|
|
94
|
+
if (decisionQuotaSinkActive()) bindRawDecisionWindows(quota, rawAnthropicUsageWindows(body, quota.updatedAt));
|
|
90
95
|
// Empty / schema-changed payloads must not cache as "success with no bars".
|
|
91
96
|
return hasQuotaRows(quota) ? markAnthropicFamilyEnumeration(quota, Array.isArray(body.limits) && body.limits.every(raw => {
|
|
92
97
|
const kind = asRecord(raw)?.kind;
|
|
@@ -493,9 +493,9 @@ export function selectProactiveApiKey(
|
|
|
493
493
|
|
|
494
494
|
const activeEntry = pool.find(entry => entry.key === provider.apiKey);
|
|
495
495
|
// A healthy committed key wins, whether the operator chose it or a previous rotation did.
|
|
496
|
-
if (activeEntry && !isKeyInCooldown(providerName, activeEntry.id, now)) return null;
|
|
496
|
+
if (activeEntry && !activeEntry.disabled && !isKeyInCooldown(providerName, activeEntry.id, now)) return null;
|
|
497
497
|
|
|
498
|
-
const eligible = pool.filter(entry => !isKeyInCooldown(providerName, entry.id, now));
|
|
498
|
+
const eligible = pool.filter(entry => !entry.disabled && !isKeyInCooldown(providerName, entry.id, now));
|
|
499
499
|
if (eligible.length === 0) return null;
|
|
500
500
|
|
|
501
501
|
let chosen = eligible[0]!;
|
|
@@ -504,7 +504,7 @@ export function selectProactiveApiKey(
|
|
|
504
504
|
const lastIndex = lastId ? pool.findIndex(entry => entry.id === lastId) : -1;
|
|
505
505
|
for (let offset = 1; offset <= pool.length; offset += 1) {
|
|
506
506
|
const candidate = pool[(lastIndex + offset) % pool.length]!;
|
|
507
|
-
if (isKeyInCooldown(providerName, candidate.id, now)) continue;
|
|
507
|
+
if (candidate.disabled || isKeyInCooldown(providerName, candidate.id, now)) continue;
|
|
508
508
|
chosen = candidate;
|
|
509
509
|
break;
|
|
510
510
|
}
|
|
@@ -518,11 +518,11 @@ export function selectProactiveApiKey(
|
|
|
518
518
|
const outcome = commitProviderApiKeySelection<string | null>(config, providerName, freshProvider => {
|
|
519
519
|
const freshPool = freshProvider.apiKeyPool ?? [];
|
|
520
520
|
const target = freshPool.find(entry => entry.id === chosen.id);
|
|
521
|
-
if (!target) return { changed: false, value: null };
|
|
521
|
+
if (!target || target.disabled) return { changed: false, value: null };
|
|
522
522
|
if (freshProvider.apiKey === target.key) return { changed: false, value: null };
|
|
523
523
|
const freshActive = freshPool.find(entry => entry.key === freshProvider.apiKey);
|
|
524
524
|
// Re-check under the lock: a concurrent manual selection may have landed a healthy key.
|
|
525
|
-
if (freshActive && !isKeyInCooldown(providerName, freshActive.id, now)) {
|
|
525
|
+
if (freshActive && !freshActive.disabled && !isKeyInCooldown(providerName, freshActive.id, now)) {
|
|
526
526
|
return { changed: false, value: null };
|
|
527
527
|
}
|
|
528
528
|
freshProvider.apiKey = target.key;
|
|
@@ -564,7 +564,7 @@ export function selectProactiveApiKeyTransport(
|
|
|
564
564
|
|
|
565
565
|
const dispatchKeyCursor = new Map<string, string>();
|
|
566
566
|
function allocateDistributedKey(providerName: string, provider: OcxProviderConfig, now: number): OcxProviderConfig {
|
|
567
|
-
const pool = (provider.apiKeyPool ?? []).filter((entry, index, all) => all.findIndex(other => other.key === entry.key) === index);
|
|
567
|
+
const pool = (provider.apiKeyPool ?? []).filter(entry => !entry.disabled).filter((entry, index, all) => all.findIndex(other => other.key === entry.key) === index);
|
|
568
568
|
const previous = dispatchKeyCursor.get(providerName);
|
|
569
569
|
const start = pool.findIndex(entry => entry.id === previous);
|
|
570
570
|
for (let offset = 1; offset <= pool.length; offset++) {
|
|
@@ -575,7 +575,7 @@ function allocateDistributedKey(providerName: string, provider: OcxProviderConfi
|
|
|
575
575
|
entryId: entry.id, reference: entry.key, revision: provider.apiKeySelectionRevision, distributed: true,
|
|
576
576
|
} };
|
|
577
577
|
}
|
|
578
|
-
const next = Math.min(...pool.map(entry => getKeyCooldownUntil(providerName, entry.id, now) ?? now + 1000));
|
|
578
|
+
const next = pool.length ? Math.min(...pool.map(entry => getKeyCooldownUntil(providerName, entry.id, now) ?? now + 1000)) : now + 1000;
|
|
579
579
|
throw new RequestPacingQueueOverloadError(providerName, "keys_exhausted", Math.max(1, Math.ceil((next - now) / 1000)));
|
|
580
580
|
}
|
|
581
581
|
|
|
@@ -711,7 +711,7 @@ export function recordDistributedKeyFailure(
|
|
|
711
711
|
const provider = config.providers[providerName];
|
|
712
712
|
if (!selection?.distributed || !provider || provider.authMode === "oauth" || provider.authMode === "forward") return false;
|
|
713
713
|
const failed = provider.apiKeyPool?.find(entry => entry.id === selection.entryId && entry.key === selection.reference);
|
|
714
|
-
if (!failed || provider.apiKeySelectionRevision !== selection.revision) return false;
|
|
714
|
+
if (!failed || failed.disabled || provider.apiKeySelectionRevision !== selection.revision) return false;
|
|
715
715
|
const delay = status === "billing" ? ANTHROPIC_BILLING_COOLDOWN_MS : status === 401 ? MAX_COOLDOWN_MS : quotaResetAt !== undefined
|
|
716
716
|
? Math.max(1, quotaResetAt - now) : parseRetryAfterMs(retryAfter, now) ?? DEFAULT_COOLDOWN_MS;
|
|
717
717
|
for (const entry of provider.apiKeyPool ?? []) if (entry.key === failed.key) {
|
|
@@ -739,7 +739,7 @@ function rotateKeyAfterFailure(
|
|
|
739
739
|
if (attemptedSelection?.distributed) {
|
|
740
740
|
if (acceptsFreshProvider && !acceptsFreshProvider(provider)) return null;
|
|
741
741
|
if (!recordDistributedKeyFailure(config, providerName, attemptedSelection, failureStatus, now, retryAfterHeader, quotaResetAt)) return null;
|
|
742
|
-
if (!(provider.apiKeyPool ?? []).some(entry => !isKeyInCooldown(providerName, entry.id, now))) return null;
|
|
742
|
+
if (!(provider.apiKeyPool ?? []).some(entry => !entry.disabled && !isKeyInCooldown(providerName, entry.id, now))) return null;
|
|
743
743
|
return allocateDistributedKey(providerName, provider, now);
|
|
744
744
|
}
|
|
745
745
|
|
|
@@ -764,7 +764,7 @@ function rotateKeyAfterFailure(
|
|
|
764
764
|
|
|
765
765
|
if (freshProvider.apiKey !== failedKey) {
|
|
766
766
|
const activeEntry = pool.find(entry => entry.key === freshProvider.apiKey);
|
|
767
|
-
if (activeEntry && !isKeyInCooldown(providerName, activeEntry.id, now)) {
|
|
767
|
+
if (activeEntry && !activeEntry.disabled && !isKeyInCooldown(providerName, activeEntry.id, now)) {
|
|
768
768
|
return {
|
|
769
769
|
changed: false,
|
|
770
770
|
value: { failedId: failedEntry?.id },
|
|
@@ -776,7 +776,7 @@ function rotateKeyAfterFailure(
|
|
|
776
776
|
const candidateCount = failedEntry ? pool.length - 1 : pool.length;
|
|
777
777
|
for (let offset = 1; offset <= candidateCount; offset += 1) {
|
|
778
778
|
const candidate = pool[(currentIndex + offset) % pool.length]!;
|
|
779
|
-
if (isKeyInCooldown(providerName, candidate.id, now)) continue;
|
|
779
|
+
if (candidate.disabled || isKeyInCooldown(providerName, candidate.id, now)) continue;
|
|
780
780
|
freshProvider.apiKey = candidate.key;
|
|
781
781
|
return {
|
|
782
782
|
changed: true,
|
|
@@ -1,22 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenCode Zen short-window rate-limit guidance (#1145 / OCX-56).
|
|
3
3
|
*
|
|
4
|
-
* OpenCode's keyed
|
|
4
|
+
* OpenCode's keyed Zen chat endpoint uses `https://opencode.ai/zen/v1`.
|
|
5
5
|
* Free-model traffic can hit a short-window burst ceiling around 15–20 RPM
|
|
6
6
|
* (community-measured). Zen often answers with opaque `429 Rate limit exceeded`
|
|
7
7
|
* bodies and may omit `Retry-After` / `X-RateLimit-*`; when those headers are
|
|
8
|
-
* present they still take precedence.
|
|
9
|
-
* ~200 requests / 5h quota documented on `opencode-free`.
|
|
10
|
-
*
|
|
11
|
-
* The same module also owns the keyless free-tier admission explanation (#4121):
|
|
12
|
-
* Zen rejects a request that carries no `x-opencode-session` header with
|
|
13
|
-
* `MissingSessionID` / "OpenCode's free tier can only be used in OpenCode".
|
|
14
|
-
* opencodex does not synthesize that header — see {@link enrichOpenCodeZenFreeTierMessage}.
|
|
8
|
+
* present they still take precedence.
|
|
15
9
|
*/
|
|
16
10
|
import { validateClientRetryAfterHeader } from "../lib/retry-after";
|
|
17
11
|
import { registryEntryForProviderDestination } from "./registry";
|
|
18
12
|
|
|
19
|
-
const OPENCODE_ZEN_PROVIDER_IDS = new Set(["opencode-zen"
|
|
13
|
+
const OPENCODE_ZEN_PROVIDER_IDS = new Set(["opencode-zen"]);
|
|
20
14
|
|
|
21
15
|
/** Observed free-model burst ceiling on Zen (not an official OpenCode figure). */
|
|
22
16
|
export const OPENCODE_ZEN_OBSERVED_RPM_HINT = "roughly 15-20 requests per minute";
|
|
@@ -49,7 +43,7 @@ export function isOpenCodeZenRateLimitProvider(opts: {
|
|
|
49
43
|
|
|
50
44
|
/**
|
|
51
45
|
* Same-key `retryOn429` only applies on key-authenticated HTTP paths — not
|
|
52
|
-
* keyless
|
|
46
|
+
* keyless traffic and not custom `runTurn` transports.
|
|
53
47
|
*/
|
|
54
48
|
export function supportsOpenCodeZenRetryOn429Guidance(opts: {
|
|
55
49
|
authMode?: string;
|
|
@@ -112,73 +106,13 @@ export function enrichOpenCodeZenRateLimitMessage(
|
|
|
112
106
|
}
|
|
113
107
|
|
|
114
108
|
/**
|
|
115
|
-
* Zen
|
|
116
|
-
* `x-opencode-session` header is refused with error type `MissingSessionID` and the
|
|
117
|
-
* message "OpenCode's free tier can only be used in OpenCode" (#4121).
|
|
118
|
-
*
|
|
119
|
-
* Presence of the header is the whole gate — any value clears it — so opencodex could
|
|
120
|
-
* pass by minting one. It does not. Fabricating a session identifier and a versioned
|
|
121
|
-
* `opencode/<version>` User-Agent is a claim to *be* the OpenCode client, and no upstream
|
|
122
|
-
* contract authorizes a third-party agent to make it; an HTTP 200 obtained that way is a
|
|
123
|
-
* bypassed admission check, not permission. Until OpenCode publishes a third-party
|
|
124
|
-
* integration path for this exact keyless tier, the supported route is the keyed
|
|
125
|
-
* `opencode-zen` provider.
|
|
126
|
-
*
|
|
127
|
-
* Two markers are matched because the two request surfaces expose different parts of the
|
|
128
|
-
* upstream envelope: the Responses path forwards the bounded raw body (which carries the
|
|
129
|
-
* `MissingSessionID` type), while the native Chat path forwards only the parsed message.
|
|
130
|
-
*/
|
|
131
|
-
const OPENCODE_ZEN_FREE_TIER_LOCK_IN = /MissingSessionID|free tier can only be used in OpenCode/i;
|
|
132
|
-
|
|
133
|
-
/** Idempotence marker — the appended guidance must not stack across enrichment layers. */
|
|
134
|
-
const FREE_TIER_ENRICHMENT_MARKER = "does not send a fabricated OpenCode session header";
|
|
135
|
-
|
|
136
|
-
/** True when an upstream error body is Zen's keyless free-tier admission refusal. */
|
|
137
|
-
export function isOpenCodeZenFreeTierLockIn(message: string, upstreamErrorType?: string | null): boolean {
|
|
138
|
-
if (upstreamErrorType && OPENCODE_ZEN_FREE_TIER_LOCK_IN.test(upstreamErrorType)) return true;
|
|
139
|
-
return OPENCODE_ZEN_FREE_TIER_LOCK_IN.test(message);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Replace a raw `MissingSessionID` passthrough with an explanation of the upstream
|
|
144
|
-
* restriction and the supported alternative. No-op for every other provider and every
|
|
145
|
-
* other error, and idempotent so layered enrichment cannot append it twice.
|
|
146
|
-
*/
|
|
147
|
-
export function enrichOpenCodeZenFreeTierMessage(
|
|
148
|
-
message: string,
|
|
149
|
-
opts: {
|
|
150
|
-
providerName?: string;
|
|
151
|
-
baseUrl?: string;
|
|
152
|
-
adapter?: string;
|
|
153
|
-
/** Upstream `error.type`, when the caller parsed one out of the envelope. */
|
|
154
|
-
upstreamErrorType?: string | null;
|
|
155
|
-
},
|
|
156
|
-
): string {
|
|
157
|
-
if (message.includes(FREE_TIER_ENRICHMENT_MARKER)) return message;
|
|
158
|
-
if (!isOpenCodeZenFreeTierLockIn(message, opts.upstreamErrorType)) return message;
|
|
159
|
-
if (!isOpenCodeZenRateLimitProvider(opts)) return message;
|
|
160
|
-
return (
|
|
161
|
-
`${message}`
|
|
162
|
-
+ " OpenCode Zen's keyless free tier admits only OpenCode's own client: it refuses any"
|
|
163
|
-
+ " request that arrives without an x-opencode-session header."
|
|
164
|
-
+ ` opencodex ${FREE_TIER_ENRICHMENT_MARKER}, because presenting itself as the OpenCode`
|
|
165
|
-
+ " client is a claim no upstream contract supports."
|
|
166
|
-
+ " Use the keyed opencode-zen provider with an OpenCode Zen API key"
|
|
167
|
-
+ " (https://opencode.ai/auth), or route this model through another provider."
|
|
168
|
-
+ " Upstream terms: https://opencode.ai/docs/zen/."
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Single entry point for Zen upstream-error guidance on Responses and native Messages: short-window
|
|
174
|
-
* rate limits first, then the keyless free-tier admission refusal. Each layer is a no-op
|
|
175
|
-
* outside its own case, so the composition is safe for every other upstream failure.
|
|
109
|
+
* Zen upstream-error guidance on Responses and native Messages.
|
|
176
110
|
*/
|
|
177
111
|
export function enrichOpenCodeZenUpstreamMessage(
|
|
178
112
|
message: string,
|
|
179
113
|
opts: Parameters<typeof enrichOpenCodeZenRateLimitMessage>[1] & { upstreamErrorType?: string | null },
|
|
180
114
|
): string {
|
|
181
|
-
return
|
|
115
|
+
return enrichOpenCodeZenRateLimitMessage(message, opts);
|
|
182
116
|
}
|
|
183
117
|
|
|
184
118
|
/** The effective HTTP endpoint, never the configured row name, identifies Console. */
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { rawAnthropicHeaderWindows } from "./anthropic-decision-windows";
|
|
2
|
+
import { clearDecisionAccountQuotas, decisionQuotaSinkActive } from "../../lib/decision-quota-sink";
|
|
3
|
+
import { bindRawDecisionWindows, publishAnthropicDecisionQuota } from "../quota-decision-publication";
|
|
1
4
|
import { parseAnthropicFamilyHeaders, mergeAnthropicFamilyWindows } from "./anthropic-family-headers";
|
|
2
5
|
import { anthropicModelQuotaFor, ANTHROPIC_PASSIVE_FAMILY_MAX_AGE_MS } from "../../oauth/anthropic-model-quota";
|
|
3
6
|
import { createHash } from "node:crypto";
|
|
@@ -289,6 +292,7 @@ export function recordAnthropicAccountQuotaFromHeadersForInstance(
|
|
|
289
292
|
writerGeneration: number,
|
|
290
293
|
status?: number,
|
|
291
294
|
model?: string,
|
|
295
|
+
decisionCredentialGeneration?: string,
|
|
292
296
|
): void {
|
|
293
297
|
if (!accountId) return;
|
|
294
298
|
const key = accountCacheKey(instance, accountId);
|
|
@@ -318,14 +322,20 @@ export function recordAnthropicAccountQuotaFromHeadersForInstance(
|
|
|
318
322
|
...(observed.customWindows ? { customWindows: mergeAnthropicFamilyWindows(previous?.quota?.customWindows, observed.customWindows) } : {}),
|
|
319
323
|
}, observed.updatedAt),
|
|
320
324
|
});
|
|
325
|
+
// Advisory evidence is published for the primary pool only; Pool 2 stays unknown to decisions.
|
|
326
|
+
if (instance === "anthropic" && generation && generation === decisionCredentialGeneration && decisionQuotaSinkActive()) {
|
|
327
|
+
bindRawDecisionWindows(observed, rawAnthropicHeaderWindows(headers, observed.updatedAt));
|
|
328
|
+
publishAnthropicDecisionQuota(accountId, generation, observed, true);
|
|
329
|
+
}
|
|
321
330
|
persistAccountQuotaCache();
|
|
322
331
|
}
|
|
323
332
|
|
|
324
333
|
/** Legacy recorder: config/roster generation is numeric and distinct from bearer ownership. */
|
|
325
334
|
export function recordAnthropicAccountQuotaFromHeaders(
|
|
326
335
|
accountId: string, headers: Headers, writerGeneration: number, status?: number, model?: string,
|
|
336
|
+
decisionCredentialGeneration?: string,
|
|
327
337
|
): void {
|
|
328
|
-
recordAnthropicAccountQuotaFromHeadersForInstance("anthropic", accountId, headers, writerGeneration, status, model);
|
|
338
|
+
recordAnthropicAccountQuotaFromHeadersForInstance("anthropic", accountId, headers, writerGeneration, status, model, decisionCredentialGeneration);
|
|
329
339
|
}
|
|
330
340
|
|
|
331
341
|
/**
|
|
@@ -446,6 +456,7 @@ export function resetProviderQuotaReconcileStateForTests(): void {
|
|
|
446
456
|
|
|
447
457
|
/** Drop cached per-account rows (all, or just one provider's). */
|
|
448
458
|
export function clearAccountQuotaCache(provider?: string): void {
|
|
459
|
+
clearDecisionAccountQuotas(provider);
|
|
449
460
|
// Anthropic readers use provider-owned epochs; B cannot revoke an A flight.
|
|
450
461
|
if (provider !== "anthropic2") explicitAccountEpoch += 1;
|
|
451
462
|
if (!provider) {
|
|
@@ -475,6 +486,9 @@ export function clearAccountQuotaCache(provider?: string): void {
|
|
|
475
486
|
|
|
476
487
|
/** Drop one account's cached bars and any joined probe so a reset is not ranked from the old window. */
|
|
477
488
|
export function forgetCachedProviderAccountQuota(provider: string, accountId: string): void {
|
|
489
|
+
// Advisory decision evidence of the same account is a copy of the dropped bars; it must not
|
|
490
|
+
// keep ranking the account from the pre-reset window while the reread is pending or fails.
|
|
491
|
+
clearDecisionAccountQuotas(provider, accountId);
|
|
478
492
|
hydrateAccountQuotaCache();
|
|
479
493
|
const key = accountCacheKey(provider, accountId);
|
|
480
494
|
accountQuotaCache.delete(key);
|
|
@@ -64,6 +64,7 @@ export async function fetchAnthropicAccountQuota(
|
|
|
64
64
|
// and a thrown read (network failure) both record the unavailable row over the retained
|
|
65
65
|
// in-flight observations, as the pre-instance A path did, so joined callers and later cached
|
|
66
66
|
// reads agree and windows that expired during the shared probe are normalized away.
|
|
67
|
+
/** Cache the settled probe result for an account and publish its decision evidence only while the flight is still current for its credential and generation. */
|
|
67
68
|
const publish = (result: AnthropicQuotaRecoveryResult | null): AccountQuotaCacheEntry => {
|
|
68
69
|
const previous = accountQuotaCache.get(key);
|
|
69
70
|
const retained = previous?.isCurrent?.() === false ? undefined : previous;
|
|
@@ -72,7 +73,7 @@ export async function fetchAnthropicAccountQuota(
|
|
|
72
73
|
ts: Date.now(), quota: normalizeAnthropicQuota(result?.quota ?? retained?.quota, Date.now()),
|
|
73
74
|
...(!result ? { unavailable: true as const } : {}), isCurrent,
|
|
74
75
|
};
|
|
75
|
-
if (isCurrent() && mayCommitAccountQuotaKey(key, writerGeneration)) { accountQuotaCache.set(key, entry); sweepExpiredOnWrite(entry.ts); }
|
|
76
|
+
if (isCurrent() && mayCommitAccountQuotaKey(key, writerGeneration)) { accountQuotaCache.set(key, entry); result?.publishDecisionQuota(); sweepExpiredOnWrite(entry.ts); }
|
|
76
77
|
return entry;
|
|
77
78
|
};
|
|
78
79
|
const probe = (async (): Promise<AccountQuotaCacheEntry> => {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { boundDecisionWindows } from "../quota-decision-publication";
|
|
2
|
+
import { decisionQuotaSinkActive, publishDecisionAccountQuota } from "../../lib/decision-quota-sink";
|
|
1
3
|
import { mergeAnthropicFamilyWindows, hasAnthropicFamilyEnumeration } from "./anthropic-family-headers";
|
|
2
4
|
import { getCachedProviderAccountQuota, captureProviderAccountQuotaEpoch } from "./account-cache";
|
|
3
5
|
import { anthropicModelQuotaFor } from "../../oauth/anthropic-model-quota";
|
|
@@ -10,6 +12,7 @@ export class AnthropicQuotaProbeOwnershipError extends Error {}
|
|
|
10
12
|
|
|
11
13
|
export type AnthropicCooldownRecoveryProbe = Readonly<{
|
|
12
14
|
instance: AnthropicInstanceId;
|
|
15
|
+
generation: string;
|
|
13
16
|
requiresFreshDispatch: boolean;
|
|
14
17
|
isCurrentCredential(): boolean;
|
|
15
18
|
isCurrentFamily(): boolean;
|
|
@@ -20,10 +23,13 @@ export type AnthropicCooldownRecoveryProbe = Readonly<{
|
|
|
20
23
|
export type AnthropicQuotaRecoveryResult = Readonly<{
|
|
21
24
|
instance: AnthropicInstanceId;
|
|
22
25
|
quota: ProviderQuota;
|
|
26
|
+
/** Publishes secret-free advisory evidence for the primary pool only; a no-op for Pool 2. */
|
|
27
|
+
publishDecisionQuota(): void;
|
|
23
28
|
/** Must be checked synchronously at each publication boundary. */
|
|
24
29
|
isCurrent(): boolean;
|
|
25
30
|
}>;
|
|
26
31
|
|
|
32
|
+
/** Create the cooldown-recovery probe owner for one Anthropic instance; its results are credential- and generation-bound before they may publish. */
|
|
27
33
|
function createAnthropicCooldownRecovery(instance: AnthropicInstanceId) {
|
|
28
34
|
const familyGeneration = (accountId: string) => anthropicModelQuotaFor(instance).anthropicFamilyQuotaGeneration(accountId);
|
|
29
35
|
|
|
@@ -129,6 +135,7 @@ function createAnthropicCooldownRecovery(instance: AnthropicInstanceId) {
|
|
|
129
135
|
};
|
|
130
136
|
return {
|
|
131
137
|
instance,
|
|
138
|
+
generation,
|
|
132
139
|
requiresFreshDispatch: claim !== null,
|
|
133
140
|
isCurrentCredential,
|
|
134
141
|
isCurrentFamily: () => familyGeneration(accountId) === capturedFamilyGeneration,
|
|
@@ -165,6 +172,7 @@ function createAnthropicCooldownRecovery(instance: AnthropicInstanceId) {
|
|
|
165
172
|
}
|
|
166
173
|
// Clearing the claimed cooldown intentionally advances the fence. Adopt that exact new
|
|
167
174
|
// generation; any later observation/429 then invalidates publication before a cache write.
|
|
175
|
+
const rawQuota = quota;
|
|
168
176
|
const ownsFamily = probe.isCurrentFamily();
|
|
169
177
|
const authoritative = ownsFamily && hasAnthropicFamilyEnumeration(quota);
|
|
170
178
|
if (ownsFamily) anthropicModelQuotaFor(instance).observeAnthropicFamilyQuota(accountId, quota.customWindows ?? [], quota.updatedAt, authoritative);
|
|
@@ -177,14 +185,24 @@ function createAnthropicCooldownRecovery(instance: AnthropicInstanceId) {
|
|
|
177
185
|
}
|
|
178
186
|
const publicationGeneration = anthropicCooldownGeneration(accountId);
|
|
179
187
|
const publicationFamilyGeneration = familyGeneration(accountId);
|
|
188
|
+
/** True while the probed credential, cooldown generation and family generation are unchanged and publication is still permitted. */
|
|
189
|
+
const isCurrent = () => probe.isCurrentCredential()
|
|
190
|
+
&& anthropicCooldownGeneration(accountId) === publicationGeneration
|
|
191
|
+
&& familyGeneration(accountId) === publicationFamilyGeneration
|
|
192
|
+
&& mayPublish();
|
|
180
193
|
afterSettlementForTests?.();
|
|
181
194
|
return {
|
|
182
195
|
instance,
|
|
183
196
|
quota,
|
|
184
|
-
isCurrent
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
197
|
+
isCurrent,
|
|
198
|
+
publishDecisionQuota: () => {
|
|
199
|
+
// Activation first: ownership validation reads the auth store and must not run for signals-off installs.
|
|
200
|
+
if (instance !== "anthropic" || !decisionQuotaSinkActive() || !isCurrent()) return;
|
|
201
|
+
// An unbound probe result was parsed while inactive: unknown, never display fallback.
|
|
202
|
+
const windows = boundDecisionWindows(rawQuota);
|
|
203
|
+
if (windows) publishDecisionAccountQuota("anthropic", accountId, probe.generation,
|
|
204
|
+
windows.filter(row => ownsFamily || ["5h", "weekly", "monthly"].includes(row.window)), !authoritative);
|
|
205
|
+
},
|
|
188
206
|
};
|
|
189
207
|
}
|
|
190
208
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Raw advisory projection; compatibility display clamping/rounding remains unchanged. */
|
|
2
|
+
import type { DecisionQuotaWindow } from "../quota-decision-snapshot";
|
|
3
|
+
import { normalizeResetAt, toFiniteNumber } from "../quota-wire";
|
|
4
|
+
/** Return a plain object value, or undefined for non-objects and arrays. */
|
|
5
|
+
const record = (value: unknown): Record<string, unknown> | undefined =>
|
|
6
|
+
value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
7
|
+
/** Normalize a reset value; an explicit invalid reset becomes NaN so it cannot read as an undated valid measurement. */
|
|
8
|
+
function reset(value: unknown): number | undefined {
|
|
9
|
+
// Explicit invalid reset must not become an apparently undated valid measurement.
|
|
10
|
+
return value === undefined || value === null ? undefined : normalizeResetAt(value) ?? NaN;
|
|
11
|
+
}
|
|
12
|
+
/** Extract raw decision windows (5h, weekly and per-model families) from an Anthropic usage body, keeping invalid resets distinct from display parsing. */
|
|
13
|
+
export function rawAnthropicUsageWindows(body: Record<string, unknown>, observedAt: number): DecisionQuotaWindow[] {
|
|
14
|
+
const rows: DecisionQuotaWindow[] = [];
|
|
15
|
+
/** Append one window when its percentage is a finite number, carrying its normalized reset. */
|
|
16
|
+
const add = (window: DecisionQuotaWindow["window"], raw: unknown, percentField: string) => {
|
|
17
|
+
const row = record(raw);
|
|
18
|
+
const percent = toFiniteNumber(row?.[percentField]);
|
|
19
|
+
if (percent === undefined) return;
|
|
20
|
+
const resetAt = reset(row?.resets_at);
|
|
21
|
+
rows.push({ window, percent, observedAt, ...(resetAt !== undefined ? { resetAt } : {}) });
|
|
22
|
+
};
|
|
23
|
+
for (const [window, field] of [["5h", "five_hour"], ["weekly", "seven_day"], ["fable", "seven_day_fable"], ["opus", "seven_day_opus"], ["sonnet", "seven_day_sonnet"]] as const) add(window, body[field], "utilization");
|
|
24
|
+
for (const limit of Array.isArray(body.limits) ? body.limits : []) {
|
|
25
|
+
const row = record(limit);
|
|
26
|
+
if (row?.kind !== "weekly_scoped") continue;
|
|
27
|
+
const display = record(record(row.scope)?.model)?.display_name;
|
|
28
|
+
if (typeof display !== "string") continue;
|
|
29
|
+
const label = display.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "").toLowerCase();
|
|
30
|
+
const family = (["fable", "opus", "sonnet"] as const).find(family => label.includes(family));
|
|
31
|
+
if (family) add(family, row, "percent");
|
|
32
|
+
}
|
|
33
|
+
return rows;
|
|
34
|
+
}
|
|
35
|
+
/** Extract decision windows from Anthropic unified rate-limit response headers, converting utilization fractions to percentages. */
|
|
36
|
+
export function rawAnthropicHeaderWindows(headers: Headers, observedAt: number): DecisionQuotaWindow[] {
|
|
37
|
+
const rows: DecisionQuotaWindow[] = [];
|
|
38
|
+
for (const [window, wire] of [["5h", "5h"], ["weekly", "7d"], ["fable", "7d_oi"]] as const) {
|
|
39
|
+
const fraction = toFiniteNumber(headers.get(`anthropic-ratelimit-unified-${wire}-utilization`));
|
|
40
|
+
if (fraction === undefined || fraction < 0 || fraction > 1) continue;
|
|
41
|
+
const rawReset = headers.get(`anthropic-ratelimit-unified-${wire}-reset`);
|
|
42
|
+
const seconds = toFiniteNumber(rawReset);
|
|
43
|
+
const resetAt = rawReset === null ? undefined : seconds !== undefined && seconds > 0 ? seconds * 1000 : NaN;
|
|
44
|
+
rows.push({ window, percent: fraction * 100, observedAt, ...(resetAt !== undefined ? { resetAt } : {}) });
|
|
45
|
+
}
|
|
46
|
+
return rows;
|
|
47
|
+
}
|
|
@@ -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);
|
|
@@ -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
|
|
@@ -300,7 +300,7 @@ export async function fetchAnthropicQuota(provider: string, config: OcxConfig =
|
|
|
300
300
|
if (!initialRow || !currentRow || initialRow.loginId !== currentRow.loginId || initialRow.addedAt !== currentRow.addedAt
|
|
301
301
|
|| epoch !== captureProviderAccountQuotaEpoch(instance) || recovery.anthropicAccountIncarnation(probedAccountId) !== incarnation) return null;
|
|
302
302
|
let quota: ProviderQuota | null;
|
|
303
|
-
let anthropicCurrent: (() => boolean) | undefined;
|
|
303
|
+
let anthropicCurrent: (() => boolean) | undefined; let anthropicPublish: (() => void) | undefined;
|
|
304
304
|
try {
|
|
305
305
|
const result = await recovery.probeAnthropicQuotaWithRecovery(probedAccountId, accessToken,
|
|
306
306
|
fresh => {
|
|
@@ -311,7 +311,7 @@ export async function fetchAnthropicQuota(provider: string, config: OcxConfig =
|
|
|
311
311
|
() => targetCurrent() && mayCommitAccountQuotaKey(probedAccountKey, writerGeneration));
|
|
312
312
|
if (result && !result.isCurrent()) return null;
|
|
313
313
|
quota = result?.quota ?? null;
|
|
314
|
-
anthropicCurrent = result?.isCurrent;
|
|
314
|
+
anthropicCurrent = result?.isCurrent; anthropicPublish = result?.publishDecisionQuota;
|
|
315
315
|
} catch (error) {
|
|
316
316
|
if (error instanceof AnthropicQuotaProbeOwnershipError) return null;
|
|
317
317
|
throw error;
|
|
@@ -322,7 +322,7 @@ export async function fetchAnthropicQuota(provider: string, config: OcxConfig =
|
|
|
322
322
|
if (probedAccountId && probedAccountKey) {
|
|
323
323
|
const stillOwnsToken = getAccountCredential(instance, probedAccountId)?.access === accessToken;
|
|
324
324
|
if (stillOwnsToken && anthropicCurrent?.() && mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) {
|
|
325
|
-
accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota, isCurrent: anthropicCurrent });
|
|
325
|
+
accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota, isCurrent: anthropicCurrent }); anthropicPublish?.();
|
|
326
326
|
}
|
|
327
327
|
}
|
|
328
328
|
const quotaReport = report(provider, "anthropic:oauth-usage", quota);
|