@bitkyc08/opencodex 2.87.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-DCd-A1az.js → Tray-7z1COnmz.js} +1 -1
- package/gui/dist/assets/index-uchD-ngf.js +94 -0
- package/gui/dist/assets/{quota-summary-ChvmKGOk.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-rejection-memo.ts +71 -0
- package/src/adapters/anthropic-replay.ts +135 -0
- package/src/adapters/anthropic-tool-catalog.ts +11 -0
- package/src/adapters/anthropic.ts +86 -48
- package/src/adapters/coding-agent/protocol.ts +6 -0
- package/src/adapters/google-http.ts +3 -2
- package/src/adapters/google-progress-history.ts +53 -0
- package/src/adapters/google-vertex-endpoint.ts +67 -0
- package/src/adapters/google.ts +52 -30
- 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 +17 -3
- package/src/bridge/response-json.ts +4 -4
- package/src/bridge/sse.ts +17 -10
- package/src/claude/inbound-content-options.ts +66 -0
- package/src/claude/inbound.ts +12 -26
- package/src/claude/outbound.ts +18 -5
- 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/cross-home-owner.ts +16 -2
- package/src/cli/help.ts +1 -1
- package/src/cli/loopback-family.ts +31 -0
- 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-gather.ts +3 -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/codex/websocket-activity.ts +161 -0
- package/src/codex/websocket-registry.ts +25 -1
- 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/opencode-zen-upgrade.ts +9 -8
- 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 +411 -175
- package/src/images/loop.ts +101 -27
- 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/request-failure-model.ts +2 -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/generic-account-failover.ts +7 -4
- 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/protocols/encoders/adapter-events.ts +5 -5
- package/src/protocols/encoders/messages.ts +2 -2
- 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 +12 -73
- 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/reasoning-replay-cache.ts +50 -4
- package/src/responses/web-search-replay.ts +138 -0
- package/src/routing/analytics.ts +7 -15
- 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 +4 -2
- package/src/server/index/live-sideband.ts +3 -2
- package/src/server/index/optional-listeners.ts +6 -0
- package/src/server/index/serve-options.ts +11 -5
- package/src/server/index/websocket-handler.ts +18 -4
- package/src/server/index.ts +7 -7
- package/src/server/lifecycle.ts +13 -1
- 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 +34 -7
- 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-continuation.ts +34 -18
- package/src/server/responses/adapter-delivery.ts +53 -6
- package/src/server/responses/adapter-dispatch.ts +104 -29
- package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
- package/src/server/responses/combo-session-recall.ts +25 -0
- package/src/server/responses/combo-stream-preflight.ts +6 -3
- package/src/server/responses/combo-tool-loop-pin.ts +70 -0
- package/src/server/responses/compact.ts +8 -5
- package/src/server/responses/compaction-recovery.ts +2 -0
- package/src/server/responses/context-overflow.ts +141 -2
- package/src/server/responses/core-codex-account.ts +9 -3
- package/src/server/responses/core-combo.ts +34 -7
- package/src/server/responses/core-lifetime.ts +3 -2
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core-replay.ts +5 -1
- 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-execution.ts +3 -2
- package/src/server/responses/passthrough-observations.ts +38 -0
- package/src/server/responses/request-compression.ts +98 -0
- package/src/server/responses/request-prepare.ts +1 -0
- package/src/server/responses/request-transport.ts +32 -3
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/response-tool-ownership.ts +21 -0
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +59 -10
- package/src/server/responses/sidecar-send-budget.ts +2 -2
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- 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/server/ws-bridge.ts +2 -0
- 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/opencode-zen.ts +9 -3
- package/src/types/provider.ts +9 -5
- package/src/types/request.ts +30 -4
- package/src/types/wire.ts +4 -2
- 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 +16 -3
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/web-search/anthropic-history.ts +59 -0
- package/src/web-search/loop.ts +212 -73
- package/src/web-search/progress-stream.ts +14 -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-ZfYbLHkB.js +0 -58
- package/gui/dist/assets/App-wT3mXqFz.css +0 -1
- package/gui/dist/assets/index-CfYDMj34.js +0 -94
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* Policy: a reference that cannot be resolved fails closed (no key) and is warned once per
|
|
17
17
|
* account; nothing ever rewrites plaintext into config or its backups.
|
|
18
18
|
*/
|
|
19
|
+
import { invalidateDecisionKeyQuotas, observeDecisionKeyCredential } from "../lib/decision-quota-sink";
|
|
19
20
|
import { resolveEnvValue } from "../config/proxy-env";
|
|
20
21
|
import { loadKeyringBinding } from "../lib/keyring-native";
|
|
21
22
|
import { isGcpCredentialMaterial } from "../lib/gcp-credential-input";
|
|
@@ -52,6 +53,7 @@ export function providerKeyResolutionGeneration(): number {
|
|
|
52
53
|
/** Test seam: swap the OS entry for an in-memory one and drop caches. */
|
|
53
54
|
export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychainEntryFactory | null): void {
|
|
54
55
|
entryFactory = factory ?? defaultEntryFactory;
|
|
56
|
+
invalidateDecisionKeyQuotas();
|
|
55
57
|
resolvedCache.clear();
|
|
56
58
|
resolutionGeneration++;
|
|
57
59
|
warnedAccounts.clear();
|
|
@@ -59,6 +61,7 @@ export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychai
|
|
|
59
61
|
|
|
60
62
|
/** Write-path seam: a store/restore mutated secrets, so cached reads and warnings are stale. */
|
|
61
63
|
export function invalidateResolvedProviderKeyCache(): void {
|
|
64
|
+
invalidateDecisionKeyQuotas();
|
|
62
65
|
resolvedCache.clear();
|
|
63
66
|
resolutionGeneration++;
|
|
64
67
|
warnedAccounts.clear();
|
|
@@ -114,15 +117,18 @@ function readKeychain(account: string, quiet = false): string | undefined {
|
|
|
114
117
|
*/
|
|
115
118
|
export function resolveProviderApiKey(value: string | undefined): string | undefined {
|
|
116
119
|
if (!value) return undefined;
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
const resolved = isKeychainReference(value) ? readKeychain(keychainAccount(value)) : resolveEnvValue(value);
|
|
121
|
+
observeDecisionKeyCredential(value, resolved);
|
|
122
|
+
return resolved;
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
/** The same resolution without the unreadable-reference warning, for observe-only callers. */
|
|
122
126
|
export function resolveProviderApiKeyQuietly(value: string | undefined): string | undefined {
|
|
123
127
|
if (!value) return undefined;
|
|
124
|
-
|
|
125
|
-
|
|
128
|
+
const resolved = isKeychainReference(value) ? readKeychain(keychainAccount(value), true) : resolveEnvValue(value);
|
|
129
|
+
// A quiet read still sees a replaced secret; advisory key evidence published under the old one must not outlive it.
|
|
130
|
+
observeDecisionKeyCredential(value, resolved);
|
|
131
|
+
return resolved;
|
|
126
132
|
}
|
|
127
133
|
|
|
128
134
|
export type ProviderKeyStoreKind = "keychain" | "env" | "file" | "none";
|
|
@@ -13,6 +13,9 @@ import { captureProviderApiKeySelection } from "./api-key-selection-capture";
|
|
|
13
13
|
export { captureProviderApiKeySelection } from "./api-key-selection-capture";
|
|
14
14
|
|
|
15
15
|
function matchesSelection(provider: OcxProviderConfig, expected: ProviderApiKeySelection): boolean {
|
|
16
|
+
if (expected.distributed) return provider.apiKeySelectionRevision === expected.revision
|
|
17
|
+
&& provider.requestPacing?.enabled === true && provider.requestPacing.keySelection === "round-robin"
|
|
18
|
+
&& !!provider.apiKeyPool?.some(entry => !entry.disabled && entry.id === expected.entryId && entry.key === expected.reference);
|
|
16
19
|
const current = captureProviderApiKeySelection(provider);
|
|
17
20
|
return current.entryId === expected.entryId && current.reference === expected.reference
|
|
18
21
|
&& current.revision === expected.revision;
|
|
@@ -24,10 +27,12 @@ function currentKeyProvider(config: OcxConfig, name: string, selection?: Provide
|
|
|
24
27
|
let row = configured;
|
|
25
28
|
if (selection?.distributed) {
|
|
26
29
|
const entry = configured.apiKeyPool?.find(key => key.id === selection.entryId && key.key === selection.reference);
|
|
27
|
-
if (!entry || (!allowCooling && isKeyInCooldown(name, entry.id)) || configured.apiKeySelectionRevision !== selection.revision
|
|
30
|
+
if (!entry || entry.disabled || (!allowCooling && isKeyInCooldown(name, entry.id)) || configured.apiKeySelectionRevision !== selection.revision
|
|
28
31
|
|| !configured.requestPacing?.enabled || configured.requestPacing.keySelection !== "round-robin") return null;
|
|
29
32
|
row = { ...configured, apiKey: entry.key, _apiKeyAttempt: selection };
|
|
30
33
|
}
|
|
34
|
+
if (row.apiKeyPool?.some(entry => entry.disabled && entry.key === row.apiKey)
|
|
35
|
+
|| row.apiKeyPool?.length && row.apiKeyPool.every(entry => entry.disabled)) return null;
|
|
31
36
|
const current = routedProviderConfig(name, { ...row, _apiKeyAttempt: selection?.distributed ? selection : undefined });
|
|
32
37
|
if (current.authMode === "oauth" || current.authMode === "forward") return null;
|
|
33
38
|
if (current.authMode === "key" && !current.keyOptional && !current.apiKey?.trim()) return null;
|
|
@@ -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;
|
|
@@ -70,6 +64,9 @@ export function enrichOpenCodeZenRateLimitMessage(
|
|
|
70
64
|
message: string,
|
|
71
65
|
opts: {
|
|
72
66
|
status: number;
|
|
67
|
+
/** Parsed upstream error type, when available. */
|
|
68
|
+
upstreamErrorType?: string | null;
|
|
69
|
+
upstreamErrorCode?: string | null;
|
|
73
70
|
providerName?: string;
|
|
74
71
|
baseUrl?: string;
|
|
75
72
|
adapter?: string;
|
|
@@ -84,7 +81,9 @@ export function enrichOpenCodeZenRateLimitMessage(
|
|
|
84
81
|
): string {
|
|
85
82
|
if (opts.status !== 429) return message;
|
|
86
83
|
if (!isOpenCodeZenRateLimitProvider(opts)) return message;
|
|
87
|
-
|
|
84
|
+
const errorText = `${opts.upstreamErrorType ?? ""} ${opts.upstreamErrorCode ?? ""} ${message}`.replace(/[_-]/g, " ");
|
|
85
|
+
if (/\b(?:quota|billing)\b|\busage\s+limit\b|\binsufficient\s+(?:credits?|balance|funds)\b|\b(?:credits?|balance)\s+(?:exhausted|depleted)\b/i.test(errorText)) return message;
|
|
86
|
+
if (opts.upstreamErrorType !== "rate_limit_error" && !/rate\s*limit/i.test(message)) return message;
|
|
88
87
|
if (message.includes(ENRICHMENT_MARKER)) return message;
|
|
89
88
|
|
|
90
89
|
const upstreamRetry = validateClientRetryAfterHeader(
|
|
@@ -107,73 +106,13 @@ export function enrichOpenCodeZenRateLimitMessage(
|
|
|
107
106
|
}
|
|
108
107
|
|
|
109
108
|
/**
|
|
110
|
-
* Zen
|
|
111
|
-
* `x-opencode-session` header is refused with error type `MissingSessionID` and the
|
|
112
|
-
* message "OpenCode's free tier can only be used in OpenCode" (#4121).
|
|
113
|
-
*
|
|
114
|
-
* Presence of the header is the whole gate — any value clears it — so opencodex could
|
|
115
|
-
* pass by minting one. It does not. Fabricating a session identifier and a versioned
|
|
116
|
-
* `opencode/<version>` User-Agent is a claim to *be* the OpenCode client, and no upstream
|
|
117
|
-
* contract authorizes a third-party agent to make it; an HTTP 200 obtained that way is a
|
|
118
|
-
* bypassed admission check, not permission. Until OpenCode publishes a third-party
|
|
119
|
-
* integration path for this exact keyless tier, the supported route is the keyed
|
|
120
|
-
* `opencode-zen` provider.
|
|
121
|
-
*
|
|
122
|
-
* Two markers are matched because the two request surfaces expose different parts of the
|
|
123
|
-
* upstream envelope: the Responses path forwards the bounded raw body (which carries the
|
|
124
|
-
* `MissingSessionID` type), while the native Chat path forwards only the parsed message.
|
|
125
|
-
*/
|
|
126
|
-
const OPENCODE_ZEN_FREE_TIER_LOCK_IN = /MissingSessionID|free tier can only be used in OpenCode/i;
|
|
127
|
-
|
|
128
|
-
/** Idempotence marker — the appended guidance must not stack across enrichment layers. */
|
|
129
|
-
const FREE_TIER_ENRICHMENT_MARKER = "does not send a fabricated OpenCode session header";
|
|
130
|
-
|
|
131
|
-
/** True when an upstream error body is Zen's keyless free-tier admission refusal. */
|
|
132
|
-
export function isOpenCodeZenFreeTierLockIn(message: string, upstreamErrorType?: string | null): boolean {
|
|
133
|
-
if (upstreamErrorType && OPENCODE_ZEN_FREE_TIER_LOCK_IN.test(upstreamErrorType)) return true;
|
|
134
|
-
return OPENCODE_ZEN_FREE_TIER_LOCK_IN.test(message);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Replace a raw `MissingSessionID` passthrough with an explanation of the upstream
|
|
139
|
-
* restriction and the supported alternative. No-op for every other provider and every
|
|
140
|
-
* other error, and idempotent so layered enrichment cannot append it twice.
|
|
141
|
-
*/
|
|
142
|
-
export function enrichOpenCodeZenFreeTierMessage(
|
|
143
|
-
message: string,
|
|
144
|
-
opts: {
|
|
145
|
-
providerName?: string;
|
|
146
|
-
baseUrl?: string;
|
|
147
|
-
adapter?: string;
|
|
148
|
-
/** Upstream `error.type`, when the caller parsed one out of the envelope. */
|
|
149
|
-
upstreamErrorType?: string | null;
|
|
150
|
-
},
|
|
151
|
-
): string {
|
|
152
|
-
if (message.includes(FREE_TIER_ENRICHMENT_MARKER)) return message;
|
|
153
|
-
if (!isOpenCodeZenFreeTierLockIn(message, opts.upstreamErrorType)) return message;
|
|
154
|
-
if (!isOpenCodeZenRateLimitProvider(opts)) return message;
|
|
155
|
-
return (
|
|
156
|
-
`${message}`
|
|
157
|
-
+ " OpenCode Zen's keyless free tier admits only OpenCode's own client: it refuses any"
|
|
158
|
-
+ " request that arrives without an x-opencode-session header."
|
|
159
|
-
+ ` opencodex ${FREE_TIER_ENRICHMENT_MARKER}, because presenting itself as the OpenCode`
|
|
160
|
-
+ " client is a claim no upstream contract supports."
|
|
161
|
-
+ " Use the keyed opencode-zen provider with an OpenCode Zen API key"
|
|
162
|
-
+ " (https://opencode.ai/auth), or route this model through another provider."
|
|
163
|
-
+ " Upstream terms: https://opencode.ai/docs/zen/."
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Single entry point for Zen upstream-error guidance on the Responses wire: short-window
|
|
169
|
-
* rate limits first, then the keyless free-tier admission refusal. Each layer is a no-op
|
|
170
|
-
* outside its own case, so the composition is safe for every other upstream failure.
|
|
109
|
+
* Zen upstream-error guidance on Responses and native Messages.
|
|
171
110
|
*/
|
|
172
111
|
export function enrichOpenCodeZenUpstreamMessage(
|
|
173
112
|
message: string,
|
|
174
113
|
opts: Parameters<typeof enrichOpenCodeZenRateLimitMessage>[1] & { upstreamErrorType?: string | null },
|
|
175
114
|
): string {
|
|
176
|
-
return
|
|
115
|
+
return enrichOpenCodeZenRateLimitMessage(message, opts);
|
|
177
116
|
}
|
|
178
117
|
|
|
179
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
|
+
}
|