@bitkyc08/opencodex 2.72.0 → 2.73.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/{App-Cf-WYstE.js → App-mHGa7dE1.js} +15 -15
- package/gui/dist/assets/{Tray-DkM0mULy.js → Tray-BxY_RYZg.js} +1 -1
- package/gui/dist/assets/{index-PWrwOfio.js → index-ur_BPgK1.js} +12 -12
- package/gui/dist/assets/{quota-summary-D-FKtrgr.js → quota-summary-D1Vq7N9H.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/cursor/native-exec.ts +28 -10
- package/src/adapters/devin/live-models.ts +5 -0
- package/src/adapters/kiro/reasoning.ts +2 -0
- package/src/adapters/openai-chat/messages.ts +4 -1
- package/src/adapters/openai-responses/passthrough.ts +12 -1
- package/src/adapters/openai-responses/reasoning.ts +15 -0
- package/src/bridge/internal.ts +12 -0
- package/src/cli/account-anthropic-threshold.ts +41 -0
- package/src/cli/account-api.ts +2 -0
- package/src/cli/account-extended.ts +32 -21
- package/src/cli/account.ts +1 -0
- package/src/cli/capabilities.ts +8 -4
- package/src/cli/index.ts +1 -1
- package/src/codex/catalog/build-entries.ts +6 -5
- package/src/codex/catalog/derive-entry.ts +3 -4
- package/src/codex/catalog/gather-capture.ts +2 -0
- package/src/codex/catalog/metadata.ts +6 -0
- package/src/codex/catalog/native-models.ts +17 -3
- package/src/codex/catalog/parsing.ts +4 -1
- package/src/codex/catalog/pinned-models.ts +3 -1
- package/src/codex/catalog/provider-models.ts +22 -2
- package/src/codex/data/roster-pinned-models.json +178 -1
- package/src/codex/model-entitlements.ts +1 -1
- package/src/codex/native-profile-store.ts +2 -1
- package/src/combos/resolve.ts +16 -1
- package/src/config/subagent-models.ts +30 -5
- package/src/generated/compatibility-version.json +95 -67
- package/src/generated/model-metadata.ts +4 -4
- package/src/lib/account-selection-events.ts +28 -0
- package/src/lib/errors.ts +7 -5
- package/src/lib/keyring-native.ts +132 -0
- package/src/lib/provider-outbound.ts +13 -0
- package/src/lib/retry-delay.ts +10 -0
- package/src/oauth/anthropic-account-threshold.ts +13 -0
- package/src/oauth/anthropic-routing.ts +223 -52
- package/src/oauth/index.ts +4 -2
- package/src/oauth/store.ts +45 -3
- package/src/oauth/types.ts +2 -0
- package/src/providers/api-key-resolve.ts +2 -4
- package/src/providers/codebuddy-models.ts +6 -0
- package/src/providers/kiro-models.ts +4 -0
- package/src/providers/label.ts +20 -1
- package/src/providers/quota/account-cache.ts +2 -1
- package/src/providers/quota/anthropic-cooldown-recovery.ts +118 -0
- package/src/providers/quota/vendor-probes-oauth.ts +84 -57
- package/src/providers/quota.ts +45 -46
- package/src/providers/registry/entries-core.ts +3 -1
- package/src/providers/registry/entries-extended.ts +14 -1
- package/src/providers/registry/model-seeds.ts +7 -1
- package/src/server/claude-messages.ts +84 -27
- package/src/server/management/anthropic-account-threshold.ts +27 -0
- package/src/server/management/oauth-account-routes.ts +17 -4
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/messages-native-oauth.ts +20 -21
- package/src/server/messages-native.ts +39 -14
- package/src/server/relay-eager.ts +5 -2
- package/src/server/relay.ts +268 -82
- package/src/server/responses/adapter-continuation.ts +6 -0
- package/src/server/responses/adapter-delivery.ts +25 -7
- package/src/server/responses/adapter-dispatch.ts +31 -0
- package/src/server/responses/buffered-sse-json.ts +345 -0
- package/src/server/responses/core-codex-account.ts +4 -1
- package/src/server/responses/core-combo.ts +28 -0
- package/src/server/responses/core-lifetime.ts +4 -0
- package/src/server/responses/passthrough-delivery.ts +305 -10
- package/src/server/responses/passthrough-dispatch.ts +60 -25
- package/src/server/responses/passthrough-error.ts +3 -1
- package/src/server/responses/request-transport.ts +22 -5
- package/src/server/responses/terminal-error-redaction.ts +53 -0
- package/src/service/windows-taskxml.ts +20 -6
- package/src/service-manager-probe.ts +115 -6
- package/src/types/wire.ts +7 -0
- package/src/usage/expected-prices.ts +11 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { OcxConfig } from "../types";
|
|
2
|
+
import type { ProviderAccount } from "./types";
|
|
3
|
+
|
|
4
|
+
/** Shared strict boundary for persisted rows and management writes. Zero is not exhaustion. */
|
|
5
|
+
export function parseAnthropicAccountThreshold(value: unknown): number | null {
|
|
6
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 100 ? value : null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Missing or malformed legacy metadata inherits; concrete zero must survive nullish fallback. */
|
|
10
|
+
export function effectiveAnthropicAccountThreshold(config: OcxConfig, account?: Pick<ProviderAccount, "autoSwitchThresholdOverride">): number {
|
|
11
|
+
return parseAnthropicAccountThreshold(account?.autoSwitchThresholdOverride)
|
|
12
|
+
?? parseAnthropicAccountThreshold(config.anthropicAccountPool?.autoSwitchThreshold) ?? 80;
|
|
13
|
+
}
|
|
@@ -34,6 +34,14 @@ import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConf
|
|
|
34
34
|
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
35
35
|
import { retainedUtf8Bytes } from "../lib/admission";
|
|
36
36
|
import { routeCandidates, type AnthropicRouteDecision } from "./anthropic-model-routes";
|
|
37
|
+
import type { ProviderQuota } from "../providers/quota-types";
|
|
38
|
+
import {
|
|
39
|
+
anthropicCooldownGeneration,
|
|
40
|
+
clearAnthropicCooldownGenerations,
|
|
41
|
+
noteAnthropicCooldownMutation,
|
|
42
|
+
} from "../providers/quota/anthropic-cooldown-recovery";
|
|
43
|
+
import { subscribeAccountSelections, subscribeOAuthAccountPauseChanges, subscribeOAuthAccountRoutingPolicyChanges } from "../lib/account-selection-events";
|
|
44
|
+
import { effectiveAnthropicAccountThreshold } from "./anthropic-account-threshold";
|
|
37
45
|
|
|
38
46
|
/**
|
|
39
47
|
* The read side of a `Headers` object, so a caller can pass the live upstream response's
|
|
@@ -57,6 +65,16 @@ export const ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST = 3;
|
|
|
57
65
|
|
|
58
66
|
export type AnthropicAccountPoolConfig = NonNullable<OcxConfig["anthropicAccountPool"]>;
|
|
59
67
|
|
|
68
|
+
/** Local admission refusal across async dispatch; never treat it as provider reachability evidence. */
|
|
69
|
+
export class AnthropicAccountCooldownError extends Error {
|
|
70
|
+
constructor(readonly retryAfterSeconds: number | null, readonly routePosition?: number) {
|
|
71
|
+
super(routePosition === undefined
|
|
72
|
+
? "All Anthropic OAuth accounts are temporarily rate-limited"
|
|
73
|
+
: "Anthropic OAuth accounts for this model route are temporarily rate-limited");
|
|
74
|
+
this.name = "AnthropicAccountCooldownError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
60
78
|
/**
|
|
61
79
|
* Where a cooldown's length came from. Same vocabulary as `CodexCooldownSource`, because it
|
|
62
80
|
* answers the same question for the same reason: `retry-after` is upstream answering THIS
|
|
@@ -66,10 +84,15 @@ export type AnthropicAccountPoolConfig = NonNullable<OcxConfig["anthropicAccount
|
|
|
66
84
|
* `retry-after` would report a drained five-hour window as request-rate throttling.
|
|
67
85
|
*/
|
|
68
86
|
type AnthropicCooldownSource = "retry-after" | "reset-derived" | "default";
|
|
87
|
+
type AnthropicQuotaWindow = "five-hour" | "weekly";
|
|
69
88
|
|
|
70
89
|
interface AccountHealth {
|
|
71
90
|
cooldownUntil: number;
|
|
72
91
|
cooldownSource: AnthropicCooldownSource;
|
|
92
|
+
/** Windows whose rejected status established a reset-derived cooldown. */
|
|
93
|
+
rejectedQuotaWindows?: AnthropicQuotaWindow[];
|
|
94
|
+
/** Monotonic fence so an older quota probe cannot erase a newer refusal. */
|
|
95
|
+
cooldownGeneration: number;
|
|
73
96
|
}
|
|
74
97
|
|
|
75
98
|
interface AffinityEntry {
|
|
@@ -105,6 +128,11 @@ export function anthropicAutoSwitchThreshold(config: OcxConfig): number {
|
|
|
105
128
|
return DEFAULT_AUTO_SWITCH_THRESHOLD;
|
|
106
129
|
}
|
|
107
130
|
|
|
131
|
+
/** Read live policy at selection, not a credential snapshot captured before an await. */
|
|
132
|
+
export function anthropicAccountAutoSwitchThreshold(config: OcxConfig, accountId: string): number {
|
|
133
|
+
return effectiveAnthropicAccountThreshold(config, getAccountSet(PROVIDER)?.accounts.find(row => row.id === accountId));
|
|
134
|
+
}
|
|
135
|
+
|
|
108
136
|
/** Strict parse for management APIs — returns null instead of defaulting. */
|
|
109
137
|
export function parseAccountPoolQuotaWindow(raw: unknown): OcxAccountPoolQuotaWindow | null {
|
|
110
138
|
if (typeof raw === "string" && VALID_QUOTA_WINDOWS.has(raw as OcxAccountPoolQuotaWindow)) {
|
|
@@ -140,11 +168,16 @@ function parseRetryAfterMs(value: string | null | undefined, now: number): numbe
|
|
|
140
168
|
}
|
|
141
169
|
|
|
142
170
|
/** Only rejected windows constrain recovery; all must reopen, so take the latest reset. */
|
|
143
|
-
function
|
|
171
|
+
function parseRateLimitReset(
|
|
172
|
+
headers: AnthropicRateLimitHeaders | null | undefined,
|
|
173
|
+
now: number,
|
|
174
|
+
): { delayMs: number; rejectedQuotaWindows: AnthropicQuotaWindow[] } | undefined {
|
|
144
175
|
if (!headers) return undefined;
|
|
145
176
|
let latest: number | undefined;
|
|
146
|
-
|
|
177
|
+
const rejectedQuotaWindows: AnthropicQuotaWindow[] = [];
|
|
178
|
+
for (const [window, quotaWindow] of [["5h", "five-hour"], ["7d", "weekly"]] as const) {
|
|
147
179
|
if (headers.get(`anthropic-ratelimit-unified-${window}-status`)?.trim() !== "rejected") continue;
|
|
180
|
+
rejectedQuotaWindows.push(quotaWindow);
|
|
148
181
|
const resetSeconds = Number(headers.get(`anthropic-ratelimit-unified-${window}-reset`)?.trim());
|
|
149
182
|
if (!Number.isFinite(resetSeconds) || resetSeconds <= 0) continue;
|
|
150
183
|
const resetAt = resetSeconds * 1000;
|
|
@@ -152,7 +185,55 @@ function parseRateLimitResetMs(headers: AnthropicRateLimitHeaders | null | undef
|
|
|
152
185
|
if (latest === undefined || resetAt > latest) latest = resetAt;
|
|
153
186
|
}
|
|
154
187
|
if (latest === undefined) return undefined;
|
|
155
|
-
return latest - now;
|
|
188
|
+
return { delayMs: latest - now, rejectedQuotaWindows };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export type AnthropicCooldownRecoveryClaim = Readonly<{
|
|
192
|
+
accountId: string;
|
|
193
|
+
cooldownGeneration: number;
|
|
194
|
+
claimedAt: number;
|
|
195
|
+
}>;
|
|
196
|
+
|
|
197
|
+
export type AnthropicCooldownRecoverySettlement = "cleared" | "retained" | "superseded";
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Capture the exact reset-derived refusal a fresh usage probe is allowed to recover.
|
|
201
|
+
*
|
|
202
|
+
* The generation fence matters because the usage request is asynchronous: a newer 429 may
|
|
203
|
+
* arrive while it is in flight, and an older answer must never erase that newer refusal.
|
|
204
|
+
*/
|
|
205
|
+
export function captureAnthropicCooldownRecovery(
|
|
206
|
+
accountId: string,
|
|
207
|
+
now = Date.now(),
|
|
208
|
+
): AnthropicCooldownRecoveryClaim | null {
|
|
209
|
+
const entry = upstreamHealth.get(accountId);
|
|
210
|
+
if (!entry || entry.cooldownUntil <= now || entry.cooldownSource !== "reset-derived") return null;
|
|
211
|
+
return { accountId, cooldownGeneration: entry.cooldownGeneration, claimedAt: now };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Clear only the claimed reset-derived cooldown when a fresh, complete usage result proves
|
|
216
|
+
* headroom in every window that upstream previously reported as rejected.
|
|
217
|
+
*/
|
|
218
|
+
export function settleAnthropicCooldownRecovery(
|
|
219
|
+
claim: AnthropicCooldownRecoveryClaim,
|
|
220
|
+
quota: ProviderQuota,
|
|
221
|
+
): AnthropicCooldownRecoverySettlement {
|
|
222
|
+
const entry = upstreamHealth.get(claim.accountId);
|
|
223
|
+
if (!entry || entry.cooldownSource !== "reset-derived"
|
|
224
|
+
|| entry.cooldownGeneration !== claim.cooldownGeneration
|
|
225
|
+
|| anthropicCooldownGeneration(claim.accountId) !== claim.cooldownGeneration
|
|
226
|
+
|| !Number.isFinite(quota.updatedAt) || quota.updatedAt < claim.claimedAt) return "superseded";
|
|
227
|
+
const windows = entry.rejectedQuotaWindows;
|
|
228
|
+
if (!windows?.length) return "retained";
|
|
229
|
+
const recovered = windows.every(window => {
|
|
230
|
+
const percent = window === "five-hour" ? quota.fiveHourPercent : quota.weeklyPercent;
|
|
231
|
+
return typeof percent === "number" && Number.isFinite(percent) && percent >= 0 && percent < 100;
|
|
232
|
+
});
|
|
233
|
+
if (!recovered) return "retained";
|
|
234
|
+
upstreamHealth.delete(claim.accountId);
|
|
235
|
+
noteAnthropicCooldownMutation(claim.accountId);
|
|
236
|
+
return "cleared";
|
|
156
237
|
}
|
|
157
238
|
|
|
158
239
|
export function getAnthropicAccountHealthSnapshot(
|
|
@@ -163,13 +244,16 @@ export function getAnthropicAccountHealthSnapshot(
|
|
|
163
244
|
if (!entry) return null;
|
|
164
245
|
if (entry.cooldownUntil <= now) {
|
|
165
246
|
upstreamHealth.delete(accountId);
|
|
247
|
+
noteAnthropicCooldownMutation(accountId);
|
|
166
248
|
return null;
|
|
167
249
|
}
|
|
168
250
|
return { cooldownUntil: entry.cooldownUntil, cooldownSource: entry.cooldownSource };
|
|
169
251
|
}
|
|
170
252
|
|
|
171
253
|
export function clearAnthropicAccountCooldown(accountId: string): boolean {
|
|
172
|
-
|
|
254
|
+
const cleared = upstreamHealth.delete(accountId);
|
|
255
|
+
if (cleared) noteAnthropicCooldownMutation(accountId);
|
|
256
|
+
return cleared;
|
|
173
257
|
}
|
|
174
258
|
|
|
175
259
|
export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number {
|
|
@@ -177,6 +261,7 @@ export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number {
|
|
|
177
261
|
for (const [accountId, health] of upstreamHealth) {
|
|
178
262
|
if (health.cooldownUntil > now) continue;
|
|
179
263
|
upstreamHealth.delete(accountId);
|
|
264
|
+
noteAnthropicCooldownMutation(accountId);
|
|
180
265
|
removed += 1;
|
|
181
266
|
}
|
|
182
267
|
return removed;
|
|
@@ -185,6 +270,7 @@ export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number {
|
|
|
185
270
|
/** Test / logout helper. */
|
|
186
271
|
export function clearAnthropicAccountPoolState(): void {
|
|
187
272
|
upstreamHealth.clear();
|
|
273
|
+
clearAnthropicCooldownGenerations();
|
|
188
274
|
sessionAffinity.clear();
|
|
189
275
|
manualPreference = undefined;
|
|
190
276
|
quorumCache = null;
|
|
@@ -266,7 +352,7 @@ export function getEligibleAnthropicAccounts(now = Date.now()): string[] {
|
|
|
266
352
|
if (!set) return [];
|
|
267
353
|
return set.accounts
|
|
268
354
|
.filter(account =>
|
|
269
|
-
account.needsReauth !== true
|
|
355
|
+
account.paused !== true && account.needsReauth !== true
|
|
270
356
|
&& !isCooled(account.id, now)
|
|
271
357
|
&& isPoolCredentialUsable(account.id, now))
|
|
272
358
|
.map(account => account.id);
|
|
@@ -301,6 +387,25 @@ export function getEligibleAnthropicAccounts(now = Date.now()): string[] {
|
|
|
301
387
|
const QUORUM_CACHE_TTL_MS = 2_000;
|
|
302
388
|
|
|
303
389
|
let quorumCache: { value: boolean; readAt: number } | null = null;
|
|
390
|
+
// Pause changes eligibility, not health. Do not reset cooldowns or cancel sent turns.
|
|
391
|
+
subscribeOAuthAccountPauseChanges(provider => { if (provider === PROVIDER) quorumCache = null; });
|
|
392
|
+
// A threshold write must fence in-flight automatic proposals, but it does not
|
|
393
|
+
// revoke an operator's one-dispatch choice. Rebase only that still-owned choice;
|
|
394
|
+
// an intervening account change clears it, so an ABA selection is not resurrected.
|
|
395
|
+
subscribeOAuthAccountRoutingPolicyChanges(event => {
|
|
396
|
+
if (event.provider !== PROVIDER || !manualPreference) return;
|
|
397
|
+
if (manualPreference.accountId !== event.before.accountId
|
|
398
|
+
|| manualPreference.revision !== event.before.revision) return;
|
|
399
|
+
manualPreference = event.after.accountId === manualPreference.accountId ? { ...event.after } : null;
|
|
400
|
+
});
|
|
401
|
+
// Any non-policy selection generation supersedes the pending one-shot choice.
|
|
402
|
+
// Threshold mutations rebase it first, before this generic notification runs.
|
|
403
|
+
subscribeAccountSelections(event => {
|
|
404
|
+
if (event.provider !== PROVIDER || event.kind !== "oauth" || !manualPreference) return;
|
|
405
|
+
const current = captureOAuthAccountSelection(PROVIDER);
|
|
406
|
+
if (current?.accountId !== manualPreference.accountId
|
|
407
|
+
|| current.revision !== manualPreference.revision) manualPreference = null;
|
|
408
|
+
});
|
|
304
409
|
|
|
305
410
|
/**
|
|
306
411
|
* Whether a 429 has somewhere to go: two or more accounts that could serve traffic if asked.
|
|
@@ -331,7 +436,7 @@ export function hasAnthropicFailoverQuorum(now = Date.now()): boolean {
|
|
|
331
436
|
if (set) {
|
|
332
437
|
let usable = 0;
|
|
333
438
|
for (const account of set.accounts) {
|
|
334
|
-
if (account.needsReauth === true) continue;
|
|
439
|
+
if (account.paused === true || account.needsReauth === true) continue;
|
|
335
440
|
if (!isPoolCredentialUsable(account.id, now)) continue;
|
|
336
441
|
if (++usable >= 2) { value = true; break; }
|
|
337
442
|
}
|
|
@@ -355,8 +460,8 @@ export function getAnthropicPoolRetryAfterSeconds(now = Date.now(), decision: An
|
|
|
355
460
|
&& !getEligibleAnthropicAccounts(now).some(id => decision.accounts.includes(id));
|
|
356
461
|
let earliest: number | null = null;
|
|
357
462
|
for (const account of set.accounts) {
|
|
463
|
+
if (account.paused === true || account.needsReauth === true || !isPoolCredentialUsable(account.id, now)) continue;
|
|
358
464
|
if (decision && !fallbackExpanded && !decision.accounts.includes(account.id)) continue;
|
|
359
|
-
if (fallbackExpanded && (account.needsReauth === true || !isPoolCredentialUsable(account.id, now))) continue;
|
|
360
465
|
const snap = getAnthropicAccountHealthSnapshot(account.id, now);
|
|
361
466
|
if (!snap?.cooldownUntil) continue;
|
|
362
467
|
if (earliest === null || snap.cooldownUntil < earliest) earliest = snap.cooldownUntil;
|
|
@@ -388,8 +493,15 @@ function compareScoredAccounts(a: ScoredAccount, b: ScoredAccount): number {
|
|
|
388
493
|
function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number, decision: AnthropicRouteDecision | null = null): string | null {
|
|
389
494
|
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
390
495
|
const unfiltered = routeCandidates(getEligibleAnthropicAccounts(now), decision).filter(id => id !== excludeId);
|
|
391
|
-
const available = window === "weekly" ? unfiltered.filter(id => !exhausted5h(id)
|
|
392
|
-
|
|
496
|
+
const available = window === "weekly" ? unfiltered.filter(id => !exhausted5h(id)
|
|
497
|
+
|| isAnthropicAccountPoolEnabled(config) && anthropicAccountAutoSwitchThreshold(config, id) === 0) : unfiltered;
|
|
498
|
+
const availableOrFallback = available.length > 0 ? available : unfiltered;
|
|
499
|
+
// Thresholds are preferences, never eligibility. Keep the old lowest-usage fallback
|
|
500
|
+
// when every candidate is drained, and keep pool-off reactive recovery policy inert.
|
|
501
|
+
const hasKnownUnderThreshold = isAnthropicAccountPoolEnabled(config)
|
|
502
|
+
&& availableOrFallback.some(id => hasKnownUsage(config, id) && isActiveUnderFillFirstThreshold(config, id));
|
|
503
|
+
const eligible = hasKnownUnderThreshold
|
|
504
|
+
? availableOrFallback.filter(id => isActiveUnderFillFirstThreshold(config, id)) : availableOrFallback;
|
|
393
505
|
if (eligible.length === 0) return null;
|
|
394
506
|
const scored: ScoredAccount[] = eligible.map(accountId => ({
|
|
395
507
|
accountId,
|
|
@@ -422,7 +534,8 @@ function pickNextFillFirstAnthropicAccount(
|
|
|
422
534
|
decision: AnthropicRouteDecision | null,
|
|
423
535
|
): string | null {
|
|
424
536
|
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
425
|
-
const available = window === "weekly" ? eligible.filter(id => !exhausted5h(id)
|
|
537
|
+
const available = window === "weekly" ? eligible.filter(id => !exhausted5h(id)
|
|
538
|
+
|| anthropicAccountAutoSwitchThreshold(config, id) === 0) : eligible;
|
|
426
539
|
const candidates = available.length > 0 ? available : eligible;
|
|
427
540
|
if (candidates.length === 0) return null;
|
|
428
541
|
const routeOrder = usesDeclaredRouteOrder(eligible, decision);
|
|
@@ -486,6 +599,7 @@ export type AnthropicAccountSelectionReason =
|
|
|
486
599
|
| "manual"
|
|
487
600
|
| "fill-first"
|
|
488
601
|
| "none"
|
|
602
|
+
| "paused"
|
|
489
603
|
| "all-cooled";
|
|
490
604
|
|
|
491
605
|
export interface AnthropicAccountSelection {
|
|
@@ -503,7 +617,7 @@ function anthropicPoolStrategy(config: OcxConfig): OcxAccountPoolRotationStrateg
|
|
|
503
617
|
}
|
|
504
618
|
|
|
505
619
|
function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean {
|
|
506
|
-
const threshold =
|
|
620
|
+
const threshold = anthropicAccountAutoSwitchThreshold(config, accountId);
|
|
507
621
|
if (threshold <= 0) return true;
|
|
508
622
|
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
509
623
|
if (window === "weekly" && exhausted5h(accountId)) return false;
|
|
@@ -568,7 +682,7 @@ function pickUnboundStrategyAccount(
|
|
|
568
682
|
|
|
569
683
|
/**
|
|
570
684
|
* Resolve which Anthropic OAuth account should serve this session.
|
|
571
|
-
* When the pool is disabled,
|
|
685
|
+
* When the pool is disabled, retain the active account unless the operator paused it.
|
|
572
686
|
*/
|
|
573
687
|
export function resolveAnthropicAccountForSession(
|
|
574
688
|
sessionKey: string | null | undefined,
|
|
@@ -579,6 +693,11 @@ export function resolveAnthropicAccountForSession(
|
|
|
579
693
|
pruneExpiredAffinity(now);
|
|
580
694
|
const set = getAccountSet(PROVIDER);
|
|
581
695
|
if (!set || set.accounts.length === 0) return { accountId: null, reason: "none", ...(decision ? { routePosition: decision.position } : {}) };
|
|
696
|
+
const scoped = decision && !decision.fallback
|
|
697
|
+
? set.accounts.filter(account => decision.accounts.includes(account.id)) : set.accounts;
|
|
698
|
+
if (scoped.length > 0 && scoped.every(account => account.paused === true)) {
|
|
699
|
+
return { accountId: null, reason: "paused", routePosition: decision?.position };
|
|
700
|
+
}
|
|
582
701
|
|
|
583
702
|
if (manualPreference === undefined) {
|
|
584
703
|
manualPreference = set.selectionRevision !== undefined
|
|
@@ -586,23 +705,24 @@ export function resolveAnthropicAccountForSession(
|
|
|
586
705
|
: null;
|
|
587
706
|
}
|
|
588
707
|
|
|
589
|
-
|
|
590
|
-
|
|
708
|
+
const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision);
|
|
709
|
+
if (eligible.length === 0) {
|
|
710
|
+
// Pause, removal and reauthentication are not cooldown evidence. Classify only
|
|
711
|
+
// usable members of this strict route (or the ordinary pool after fallback widens).
|
|
712
|
+
const recoverable = set.accounts.filter(account =>
|
|
713
|
+
(!decision || decision.fallback || decision.accounts.includes(account.id))
|
|
714
|
+
&& account.paused !== true && account.needsReauth !== true && isPoolCredentialUsable(account.id, now));
|
|
715
|
+
const cooled = recoverable.length > 0 && recoverable.every(account => isCooled(account.id, now));
|
|
716
|
+
if (cooled || decision) return { accountId: null, reason: cooled ? "all-cooled" : "none", routePosition: decision?.position };
|
|
591
717
|
}
|
|
592
718
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
if (
|
|
597
|
-
|
|
598
|
-
// ordinary pool once those accounts recover. Only usable stored accounts count.
|
|
599
|
-
const ordinary = set.accounts.filter(account =>
|
|
600
|
-
account.needsReauth !== true && isPoolCredentialUsable(account.id, now));
|
|
601
|
-
cooled = ordinary.length > 0 && ordinary.every(account => isCooled(account.id, now));
|
|
602
|
-
} else {
|
|
603
|
-
cooled = decision.accounts.every(id => set.accounts.some(account => account.id === id && isCooled(id, now)));
|
|
719
|
+
if (!isAnthropicAccountPoolEnabled(config)) {
|
|
720
|
+
const active = set.accounts.find(account => account.id === set.activeAccountId);
|
|
721
|
+
// Disabled proactive rotation does not authorize a paused slot or an entirely cooled pool.
|
|
722
|
+
if (active?.paused || isCooled(set.activeAccountId, now)) {
|
|
723
|
+
return { accountId: eligible[0] ?? null, reason: eligible.length > 0 ? "only-eligible" : "none" };
|
|
604
724
|
}
|
|
605
|
-
return { accountId:
|
|
725
|
+
return { accountId: set.activeAccountId, reason: "pool-disabled" };
|
|
606
726
|
}
|
|
607
727
|
|
|
608
728
|
// A manual choice is a one-dispatch preference, not a lower-priority quota hint.
|
|
@@ -626,7 +746,7 @@ export function resolveAnthropicAccountForSession(
|
|
|
626
746
|
if (key) {
|
|
627
747
|
const affined = sessionAffinity.get(key);
|
|
628
748
|
if (affined && now - affined.lastUsedAt <= AFFINITY_IDLE_TTL_MS) {
|
|
629
|
-
const stillThere = set.accounts.some(a => a.id === affined.accountId && a.needsReauth !== true);
|
|
749
|
+
const stillThere = set.accounts.some(a => a.id === affined.accountId && a.paused !== true && a.needsReauth !== true);
|
|
630
750
|
const stillUsable = stillThere && !isCooled(affined.accountId, now)
|
|
631
751
|
&& isPoolCredentialUsable(affined.accountId, now);
|
|
632
752
|
if (stillUsable && eligible.includes(affined.accountId)) {
|
|
@@ -656,7 +776,7 @@ export function resolveAnthropicAccountForSession(
|
|
|
656
776
|
return { accountId: strategyPick.accountId, reason: strategyPick.reason, routePosition: decision?.position };
|
|
657
777
|
}
|
|
658
778
|
|
|
659
|
-
const threshold =
|
|
779
|
+
const threshold = anthropicAccountAutoSwitchThreshold(config, set.activeAccountId);
|
|
660
780
|
const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true)
|
|
661
781
|
&& !isCooled(set.activeAccountId, now)
|
|
662
782
|
&& eligible.includes(set.activeAccountId);
|
|
@@ -694,13 +814,36 @@ export function resolveAnthropicAccountForSession(
|
|
|
694
814
|
}
|
|
695
815
|
|
|
696
816
|
if (!accountId) {
|
|
697
|
-
const anyCooled = set.accounts.some(a => (!decision || decision.accounts.includes(a.id)) && isCooled(a.id, now));
|
|
817
|
+
const anyCooled = set.accounts.some(a => !a.paused && (!decision || decision.accounts.includes(a.id)) && isCooled(a.id, now));
|
|
698
818
|
return { accountId: null, reason: anyCooled ? "all-cooled" : "none", routePosition: decision?.position };
|
|
699
819
|
}
|
|
700
820
|
|
|
701
821
|
return { accountId, reason, routePosition: decision?.position };
|
|
702
822
|
}
|
|
703
823
|
|
|
824
|
+
/** Shared local refusal policy for Responses and native Messages after asynchronous waits. */
|
|
825
|
+
export async function resolveAnthropicDispatchAccountId(
|
|
826
|
+
config: OcxConfig,
|
|
827
|
+
sessionKey: string | null = null,
|
|
828
|
+
decision: AnthropicRouteDecision | null = null,
|
|
829
|
+
): Promise<string> {
|
|
830
|
+
const now = Date.now();
|
|
831
|
+
const selection = resolveAnthropicAccountForSession(sessionKey, config, now, decision);
|
|
832
|
+
if (selection.reason === "all-cooled") {
|
|
833
|
+
throw new AnthropicAccountCooldownError(getAnthropicPoolRetryAfterSeconds(now, decision), decision?.position);
|
|
834
|
+
}
|
|
835
|
+
if (selection.reason === "paused" || !selection.accountId || !getEligibleAnthropicAccounts(now).includes(selection.accountId)) {
|
|
836
|
+
const { OAuthAccountPausedError, OAuthLoginRequiredError } = await import("./index");
|
|
837
|
+
const active = getAccountSet(PROVIDER)?.activeAccountId;
|
|
838
|
+
// Pool-off fresh admission resolves the active credential first. Preserve that
|
|
839
|
+
// paused-active refusal when no usable survivor could take over from it.
|
|
840
|
+
if (selection.reason === "paused" || (!isAnthropicAccountPoolEnabled(config) && active
|
|
841
|
+
&& getAccountCredentialWithStatus(PROVIDER, active)?.paused)) throw new OAuthAccountPausedError();
|
|
842
|
+
throw new OAuthLoginRequiredError(PROVIDER);
|
|
843
|
+
}
|
|
844
|
+
return selection.accountId;
|
|
845
|
+
}
|
|
846
|
+
|
|
704
847
|
export function bindAnthropicSessionAffinity(
|
|
705
848
|
sessionKey: string | null | undefined,
|
|
706
849
|
accountId: string,
|
|
@@ -736,50 +879,68 @@ export function rotateAnthropicAccountOn429(
|
|
|
736
879
|
rateLimitHeaders?: AnthropicRateLimitHeaders | null,
|
|
737
880
|
decision: AnthropicRouteDecision | null = null,
|
|
738
881
|
): string | null {
|
|
882
|
+
if (!recordAnthropicAccount429(config, failedAccountId, retryAfterHeader, now, rateLimitHeaders)) return null;
|
|
883
|
+
|
|
884
|
+
// The pool's strategy is a PROACTIVE policy. When the pool is disabled, reactive
|
|
885
|
+
// presence-only recovery must not silently reactivate round-robin/fill-first merely
|
|
886
|
+
// because those dormant values remain in config. The quota picker is the neutral
|
|
887
|
+
// recovery policy already used by the default strategy.
|
|
888
|
+
const next = isAnthropicAccountPoolEnabled(config)
|
|
889
|
+
? pickAlternateAnthropicAccount(config, failedAccountId, now, decision)
|
|
890
|
+
: pickLowestUsage(config, failedAccountId, now);
|
|
891
|
+
if (!next) {
|
|
892
|
+
console.warn(`[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}no eligible replacement; returning 429`);
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
console.warn(
|
|
897
|
+
`[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}429 on ${formatAnthropicAccountOrdinal(failedAccountId)}; failing over to ${formatAnthropicAccountOrdinal(next)}`,
|
|
898
|
+
);
|
|
899
|
+
return next;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Record the account refusal even when this request has no remaining retry sends. */
|
|
903
|
+
export function recordAnthropicAccount429(
|
|
904
|
+
config: OcxConfig,
|
|
905
|
+
failedAccountId: string,
|
|
906
|
+
retryAfterHeader: string | null | undefined,
|
|
907
|
+
now = Date.now(),
|
|
908
|
+
rateLimitHeaders?: AnthropicRateLimitHeaders | null,
|
|
909
|
+
): boolean {
|
|
739
910
|
// Reactive 429 failover is NOT gated on the pool flag. That flag buys PROACTIVE routing --
|
|
740
911
|
// session affinity, quota-ranked new-session selection, autoSwitchThreshold, strategy -- all
|
|
741
912
|
// of which move a HEALTHY request and stay opt-in. Rotating away from an account upstream has
|
|
742
913
|
// just rate-limited is a different thing: it only ever runs after a refusal, and stranding a
|
|
743
914
|
// 429 while a second logged-in account sits idle is a defect, not a configuration choice.
|
|
744
915
|
// Presence is the activation rule, the same one an apiKeyPool of two keys already uses.
|
|
745
|
-
|
|
916
|
+
// A sent turn may finish after its account is paused; its one remaining successor is
|
|
917
|
+
// still a valid reactive recovery even though the current unpaused quorum is now one.
|
|
918
|
+
if (!isAnthropicAccountPoolEnabled(config) && !hasAnthropicFailoverQuorum(now)
|
|
919
|
+
&& !getAccountCredentialWithStatus(PROVIDER, failedAccountId)?.paused) return false;
|
|
746
920
|
|
|
747
921
|
// Retry-After first: it is the header written FOR this decision. The rejected window's
|
|
748
922
|
// reset is the fallback, because a 429 that omits Retry-After still carries it -- and
|
|
749
923
|
// without that fallback such a refusal cools for the 60s default and the exhausted
|
|
750
924
|
// account is back in the rotation a minute later.
|
|
751
925
|
const parsedRetry = parseRetryAfterMs(retryAfterHeader, now);
|
|
752
|
-
const resetDerived = parsedRetry === undefined ?
|
|
753
|
-
const cooldownMs = parsedRetry ?? resetDerived ?? DEFAULT_COOLDOWN_MS;
|
|
926
|
+
const resetDerived = parsedRetry === undefined ? parseRateLimitReset(rateLimitHeaders, now) : undefined;
|
|
927
|
+
const cooldownMs = parsedRetry ?? resetDerived?.delayMs ?? DEFAULT_COOLDOWN_MS;
|
|
928
|
+
const cooldownGeneration = noteAnthropicCooldownMutation(failedAccountId);
|
|
754
929
|
upstreamHealth.set(failedAccountId, {
|
|
755
930
|
cooldownUntil: now + cooldownMs,
|
|
756
931
|
cooldownSource: parsedRetry !== undefined
|
|
757
932
|
? "retry-after"
|
|
758
933
|
: resetDerived !== undefined ? "reset-derived" : "default",
|
|
934
|
+
...(resetDerived ? { rejectedQuotaWindows: resetDerived.rejectedQuotaWindows } : {}),
|
|
935
|
+
cooldownGeneration,
|
|
759
936
|
});
|
|
760
937
|
sweepExpiredOnWrite(now);
|
|
761
938
|
clearAnthropicSessionAffinityForAccount(failedAccountId);
|
|
762
939
|
notePoolRotationFailure(POOL_KEY_ANTHROPIC, failedAccountId);
|
|
763
|
-
//
|
|
764
|
-
// from a count read taken before the failure.
|
|
940
|
+
// The refused account changed the eligible roster even when no retry send remains.
|
|
765
941
|
quorumCache = null;
|
|
766
942
|
|
|
767
|
-
|
|
768
|
-
// presence-only recovery must not silently reactivate round-robin/fill-first merely
|
|
769
|
-
// because those dormant values remain in config. The quota picker is the neutral
|
|
770
|
-
// recovery policy already used by the default strategy.
|
|
771
|
-
const next = isAnthropicAccountPoolEnabled(config)
|
|
772
|
-
? pickAlternateAnthropicAccount(config, failedAccountId, now, decision)
|
|
773
|
-
: pickLowestUsage(config, failedAccountId, now);
|
|
774
|
-
if (!next) {
|
|
775
|
-
console.warn(`[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}no eligible replacement; returning 429`);
|
|
776
|
-
return null;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
console.warn(
|
|
780
|
-
`[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}429 on ${formatAnthropicAccountOrdinal(failedAccountId)}; failing over to ${formatAnthropicAccountOrdinal(next)}`,
|
|
781
|
-
);
|
|
782
|
-
return next;
|
|
943
|
+
return true;
|
|
783
944
|
}
|
|
784
945
|
|
|
785
946
|
export interface AnthropicSelectionRoutingOptions {
|
|
@@ -813,7 +974,7 @@ export function commitAnthropicSelectionRouting(
|
|
|
813
974
|
committed: OAuthAccountSelection,
|
|
814
975
|
options: AnthropicSelectionRoutingOptions,
|
|
815
976
|
): boolean {
|
|
816
|
-
if (committed.accountId !== accountId || (options.routeDecision
|
|
977
|
+
if (getAccountCredentialWithStatus(PROVIDER, accountId)?.paused || committed.accountId !== accountId || (options.routeDecision
|
|
817
978
|
&& !routeCandidates(getEligibleAnthropicAccounts(), options.routeDecision).includes(accountId))) return false;
|
|
818
979
|
const current = captureOAuthAccountSelection(PROVIDER);
|
|
819
980
|
if (current?.accountId !== committed.accountId || current.revision !== committed.revision) return false;
|
|
@@ -857,7 +1018,12 @@ export function resetAnthropicRoutingForManualSelection(accountId: string): void
|
|
|
857
1018
|
* as quota probes).
|
|
858
1019
|
*/
|
|
859
1020
|
export async function getAnthropicPoolAccessToken(accountId: string): Promise<string> {
|
|
860
|
-
const
|
|
1021
|
+
const row = getAccountCredentialWithStatus(PROVIDER, accountId);
|
|
1022
|
+
if (row?.paused) {
|
|
1023
|
+
const { OAuthAccountPausedError } = await import("./index");
|
|
1024
|
+
throw new OAuthAccountPausedError();
|
|
1025
|
+
}
|
|
1026
|
+
const stored = row?.credential;
|
|
861
1027
|
if (!stored) {
|
|
862
1028
|
const { OAuthLoginRequiredError } = await import("./index");
|
|
863
1029
|
throw new OAuthLoginRequiredError(PROVIDER);
|
|
@@ -874,6 +1040,10 @@ export async function getAnthropicPoolAccessToken(accountId: string): Promise<st
|
|
|
874
1040
|
export async function getAnthropicPoolAccessSnapshot(accountId: string): Promise<OAuthAccessSnapshot> {
|
|
875
1041
|
const accessToken = await getAnthropicPoolAccessToken(accountId);
|
|
876
1042
|
const row = getAccountCredentialWithStatus(PROVIDER, accountId);
|
|
1043
|
+
if (row?.paused) {
|
|
1044
|
+
const { OAuthAccountPausedError } = await import("./index");
|
|
1045
|
+
throw new OAuthAccountPausedError();
|
|
1046
|
+
}
|
|
877
1047
|
if (!row || row.needsReauth || row.credential.access !== accessToken
|
|
878
1048
|
|| row.credential.expires <= Date.now()) {
|
|
879
1049
|
throw new Error("Anthropic pool credential changed during account selection");
|
|
@@ -887,6 +1057,7 @@ export async function getAnthropicPoolAccessSnapshot(accountId: string): Promise
|
|
|
887
1057
|
*/
|
|
888
1058
|
export function canRefreshAnthropicPoolAccount(accountId: string): boolean {
|
|
889
1059
|
const set = getAccountSet(PROVIDER);
|
|
1060
|
+
if (set?.accounts.find(account => account.id === accountId)?.paused) return false;
|
|
890
1061
|
const cred = getAccountCredential(PROVIDER, accountId);
|
|
891
1062
|
if (!cred) return false;
|
|
892
1063
|
if (cred.source !== "local-cli") return true;
|
package/src/oauth/index.ts
CHANGED
|
@@ -925,8 +925,10 @@ export async function refreshAnthropicAccountWithLock(
|
|
|
925
925
|
const now = deps.now ?? Date.now;
|
|
926
926
|
const guard = await (deps.intentLock ?? createOAuthRefreshIntentLock(provider, accountId)).acquire();
|
|
927
927
|
try {
|
|
928
|
-
const
|
|
929
|
-
if (!
|
|
928
|
+
const row = getAccountCredentialWithStatus(provider, accountId);
|
|
929
|
+
if (!row) throw new OAuthLoginRequiredError(provider);
|
|
930
|
+
if (row.paused) throw new OAuthAccountPausedError();
|
|
931
|
+
const stored = row.credential;
|
|
930
932
|
const account = getAccountSet(provider)?.accounts.find(candidate => candidate.id === accountId);
|
|
931
933
|
const generation = credentialGeneration(stored);
|
|
932
934
|
let pendingIntent = readOAuthRefreshIntent(provider, accountId);
|
package/src/oauth/store.ts
CHANGED
|
@@ -27,13 +27,14 @@ import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write";
|
|
|
27
27
|
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
28
28
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
29
29
|
import { MAX_PENDING_OAUTH_MUTATIONS } from "../lib/translator-budget";
|
|
30
|
-
import { publishAccountSelection, publishOAuthAccountPauseChange } from "../lib/account-selection-events";
|
|
30
|
+
import { publishAccountSelection, publishOAuthAccountPauseChange, publishOAuthAccountRoutingPolicyChange } from "../lib/account-selection-events";
|
|
31
31
|
import {
|
|
32
32
|
captureConfigGeneration,
|
|
33
33
|
type GenerationContext,
|
|
34
34
|
} from "../lib/state-store-sweeper";
|
|
35
35
|
import { validateCopilotApiBaseUrl } from "./github-copilot";
|
|
36
36
|
import { validateDevinApiBaseUrl } from "./devin/api-base";
|
|
37
|
+
import { parseAnthropicAccountThreshold } from "./anthropic-account-threshold";
|
|
37
38
|
import type { OAuthAccountSelection, OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types";
|
|
38
39
|
|
|
39
40
|
export type AuthStore = Record<string, ProviderAccountSet>;
|
|
@@ -626,6 +627,8 @@ function normalizeAccount(value: unknown): ProviderAccount | null {
|
|
|
626
627
|
if (typeof candidate.alias === "string" && candidate.alias.trim()) account.alias = candidate.alias.trim();
|
|
627
628
|
if (candidate.needsReauth === true) account.needsReauth = true;
|
|
628
629
|
if (candidate.paused === true) account.paused = true;
|
|
630
|
+
const threshold = parseAnthropicAccountThreshold(candidate.autoSwitchThresholdOverride);
|
|
631
|
+
if (threshold !== null) account.autoSwitchThresholdOverride = threshold;
|
|
629
632
|
if (typeof candidate.addedAt === "number") account.addedAt = candidate.addedAt;
|
|
630
633
|
if (typeof candidate.loginId === "string"
|
|
631
634
|
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate.loginId)) {
|
|
@@ -783,7 +786,7 @@ function serializeMutation<T>(work: () => Promise<T>, retainedValues: readonly u
|
|
|
783
786
|
drainOAuthMutations();
|
|
784
787
|
return result;
|
|
785
788
|
}
|
|
786
|
-
export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void; scrubLegacyBackup?: (result: T) => readonly string[]; finalizeResult?: (result: T, store: AuthStore) => void }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
|
|
789
|
+
export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void; scrubLegacyBackup?: (result: T) => readonly string[]; finalizeResult?: (result: T, store: AuthStore) => void; afterPersist?: (result: T) => void }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
|
|
787
790
|
const { store, hadLegacy } = loadAuthStoreInternal();
|
|
788
791
|
if (hadLegacy) backupLegacyOnce();
|
|
789
792
|
const selections = new Map(Object.entries(store).map(([provider, set]) => [provider, {
|
|
@@ -821,6 +824,9 @@ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValue
|
|
|
821
824
|
options?.finalizeResult?.(result, store);
|
|
822
825
|
persist(store);
|
|
823
826
|
if (scrubbedProviders.length > 0) scrubLegacyBackup(scrubbedProviders);
|
|
827
|
+
// A committed observer may establish ordering before the generic selection
|
|
828
|
+
// publication, but its failure can never turn a durable write into a reported failure.
|
|
829
|
+
try { options?.afterPersist?.(result); } catch { /* The authoritative write already committed. */ }
|
|
824
830
|
for (const provider of changedProviders) publishAccountSelection(provider, "oauth");
|
|
825
831
|
return result;
|
|
826
832
|
}finally{guard.release();}}, retainedValues, options?.waitMs);
|
|
@@ -1224,6 +1230,39 @@ export type SetAccountPausedResult =
|
|
|
1224
1230
|
| { status: "unchanged"; activeAccountId: string; activeAccountChanged: boolean }
|
|
1225
1231
|
| { status: "not-found" };
|
|
1226
1232
|
|
|
1233
|
+
/** Serialize policy with refresh/removal; stale pre-wait selection proposals must retry. */
|
|
1234
|
+
export async function setAnthropicAccountThreshold(
|
|
1235
|
+
accountId: string,
|
|
1236
|
+
threshold: number | null,
|
|
1237
|
+
options: { assertBeforePersist?: () => void } = {},
|
|
1238
|
+
): Promise<boolean> {
|
|
1239
|
+
const normalizedThreshold = threshold === null ? null : parseAnthropicAccountThreshold(threshold);
|
|
1240
|
+
if (threshold !== null && normalizedThreshold === null) {
|
|
1241
|
+
throw new Error("threshold must be an integer 0-100 or null");
|
|
1242
|
+
}
|
|
1243
|
+
const result = await mutateStore(store => {
|
|
1244
|
+
const set = store.anthropic;
|
|
1245
|
+
const account = set?.accounts.find(row => row.id === accountId);
|
|
1246
|
+
if (!set || !account) return { status: "not-found" as const };
|
|
1247
|
+
if ((account.autoSwitchThresholdOverride ?? null) === normalizedThreshold) return { status: "unchanged" as const };
|
|
1248
|
+
const before = accountSelection(set);
|
|
1249
|
+
if (normalizedThreshold === null) delete account.autoSwitchThresholdOverride;
|
|
1250
|
+
else account.autoSwitchThresholdOverride = normalizedThreshold;
|
|
1251
|
+
set.selectionRevision = randomUUID();
|
|
1252
|
+
return { status: "updated" as const, before, after: accountSelection(set) };
|
|
1253
|
+
}, [accountId, normalizedThreshold], { assertBeforePersist: options.assertBeforePersist, afterPersist: result => {
|
|
1254
|
+
if (result.status !== "updated") return;
|
|
1255
|
+
// Publish the policy-owned transition before the generic selection event. This
|
|
1256
|
+
// preserves an exact previous-revision manual intent without opening an ABA gap.
|
|
1257
|
+
publishOAuthAccountRoutingPolicyChange(Object.freeze({
|
|
1258
|
+
provider: "anthropic",
|
|
1259
|
+
before: Object.freeze(result.before),
|
|
1260
|
+
after: Object.freeze(result.after),
|
|
1261
|
+
}));
|
|
1262
|
+
} });
|
|
1263
|
+
return result.status !== "not-found";
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1227
1266
|
/** Persist an operator pause and move an active account to the next usable unpaused slot when available. */
|
|
1228
1267
|
export async function setAccountPaused(
|
|
1229
1268
|
provider: string,
|
|
@@ -1323,6 +1362,7 @@ export async function replaceProviderAccountSet(
|
|
|
1323
1362
|
...(account.alias ? { alias: account.alias } : {}),
|
|
1324
1363
|
...(account.needsReauth ? { needsReauth: true } : {}),
|
|
1325
1364
|
...(account.paused ? { paused: true } : {}),
|
|
1365
|
+
...(account.autoSwitchThresholdOverride !== undefined ? { autoSwitchThresholdOverride: account.autoSwitchThresholdOverride } : {}),
|
|
1326
1366
|
...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}),
|
|
1327
1367
|
...(account.loginId ? { loginId: account.loginId } : {}),
|
|
1328
1368
|
})),
|
|
@@ -1380,4 +1420,6 @@ export async function markAccountNeedsReauth(
|
|
|
1380
1420
|
}
|
|
1381
1421
|
|
|
1382
1422
|
export async function mergeAccountCredential(provider:string,accountId:string,credential:OAuthCredentials,opts:{expectedGeneration?:string;afterPrePersistRead?:()=>void|Promise<void>;assertOwnership?: (store: AuthStore) => void}={}):Promise<{superseded:false}|{superseded:true;stored:OAuthCredentials}>{const safe=normalizeCredential(credential);if(!safe)throw new Error("Refusing to persist invalid OAuth credential");return await mutateStore(async store=>{await opts.afterPrePersistRead?.();const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account)throw new Error(`OAuth account disappeared before persist: ${provider}`);if(opts.expectedGeneration!==undefined&&credentialGeneration(account.credential)!==opts.expectedGeneration)return{superseded:true,stored:account.credential};opts.assertOwnership?.(store);account.credential=safe;delete account.needsReauth;return{superseded:false};},[provider,accountId,safe,opts.expectedGeneration]);}
|
|
1383
|
-
|
|
1423
|
+
// A late refresh failure must not change an operator-paused account's health. Check under
|
|
1424
|
+
// the mutation lock, not before awaiting it, so a concurrent pause cannot be overwritten.
|
|
1425
|
+
export async function markAccountNeedsReauthIfGeneration(provider:string,accountId:string,generation:string,writerGeneration=captureConfigGeneration()):Promise<boolean>{const key=oauthAccountKey(provider,accountId);if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;return await mutateStore(store=>{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||account.paused||credentialGeneration(account.credential)!==generation)return false;if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;account.needsReauth=true;return true;},[provider,accountId,generation]);}
|
package/src/oauth/types.ts
CHANGED
|
@@ -90,6 +90,8 @@ export interface ProviderAccount {
|
|
|
90
90
|
needsReauth?: boolean;
|
|
91
91
|
/** Operator exclusion from generic OAuth account selection until explicitly resumed. */
|
|
92
92
|
paused?: boolean;
|
|
93
|
+
/** Anthropic-only usage-switch override; absent inherits its pool default, zero disables it. */
|
|
94
|
+
autoSwitchThresholdOverride?: number;
|
|
93
95
|
addedAt?: number;
|
|
94
96
|
}
|
|
95
97
|
|