@bitkyc08/opencodex 2.75.0 → 2.76.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 +6 -1
- package/bin/ocx.mjs +12 -1
- package/gui/dist/assets/App-CddorHdA.js +51 -0
- package/gui/dist/assets/App-DMudtISW.css +1 -0
- package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
- package/gui/dist/assets/index-Btze1I3T.css +1 -0
- package/gui/dist/assets/index-CvnY7E6i.js +86 -0
- package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/opengateway.svg +12 -0
- package/package.json +1 -1
- package/src/adapters/anthropic/account-metadata.ts +17 -0
- package/src/adapters/anthropic/client-identity.ts +69 -0
- package/src/adapters/anthropic/passthrough.ts +23 -4
- package/src/adapters/anthropic.ts +18 -1
- package/src/adapters/command-code-project-context.ts +7 -11
- package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
- package/src/adapters/kiro-retry.ts +23 -8
- package/src/adapters/openai-chat/messages.ts +1 -1
- package/src/adapters/registry.ts +8 -1
- package/src/adapters/zed.ts +377 -0
- package/src/bridge/sse.ts +4 -2
- package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
- package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
- package/src/chatgpt/app-server-shim/filter.ts +157 -0
- package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
- package/src/chatgpt/app-server-shim/launcher.ts +91 -0
- package/src/claude/desktop-3p-startup.ts +62 -0
- package/src/claude/desktop-3p.ts +5 -0
- package/src/claude/first-party-settings.ts +14 -0
- package/src/claude/intercept/cli-catalog.ts +150 -0
- package/src/claude/intercept/cli-picker.ts +82 -0
- package/src/claude/intercept/listener.ts +21 -6
- package/src/claude/intercept/picker-bootstrap.ts +23 -5
- package/src/claude/intercept/picker-models.ts +57 -13
- package/src/claude/intercept/runtime.ts +30 -2
- package/src/claude/intercept/settings.ts +3 -0
- package/src/cli/agent.ts +172 -0
- package/src/cli/alias.ts +2 -1
- package/src/cli/capabilities-command.ts +3 -2
- package/src/cli/capabilities.ts +87 -0
- package/src/cli/chatgpt-command.ts +140 -0
- package/src/cli/combo.ts +144 -19
- package/src/cli/config-command.ts +3 -1
- package/src/cli/dispatch.ts +10 -1
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +5 -24
- package/src/cli/init.ts +16 -2
- package/src/cli/integrations.ts +13 -0
- package/src/cli/internal-command.ts +12 -5
- package/src/cli/observe.ts +6 -1
- package/src/cli/registry.ts +6 -0
- package/src/cli/route-policy.ts +3 -1
- package/src/cli/runtime-api.ts +5 -2
- package/src/cli/status-oauth.ts +5 -0
- package/src/cli/status.ts +8 -1
- package/src/cli/stop-approval.ts +55 -16
- package/src/cli/stop-report.ts +5 -0
- package/src/clients/lazycodex.ts +48 -0
- package/src/clients/omo-role-models.ts +111 -0
- package/src/codex/account-credit-use.ts +99 -0
- package/src/codex/account-lifecycle.ts +2 -0
- package/src/codex/agent-role-models.ts +171 -0
- package/src/codex/auth-api/account-list.ts +10 -3
- package/src/codex/auth-api/routes.ts +34 -0
- package/src/codex/auth-context.ts +52 -11
- package/src/codex/catalog/discovered-natives.ts +11 -1
- package/src/codex/catalog/gather-capture.ts +6 -1
- package/src/codex/catalog/model-hints.ts +9 -1
- package/src/codex/catalog/provider-models.ts +60 -0
- package/src/codex/catalog/routed-gather.ts +30 -5
- package/src/codex/convergence-types.ts +2 -1
- package/src/codex/main-account-external-usage.ts +62 -0
- package/src/codex/main-account-hard-lock.ts +24 -13
- package/src/codex/native-profile-processes.ts +53 -16
- package/src/codex/prompt-layers/toml-edit.ts +1 -1
- package/src/codex/quota-types.ts +3 -7
- package/src/codex/quota.ts +19 -4
- package/src/codex/role-auto-assign.ts +159 -0
- package/src/codex/role-sizing-limits.ts +2 -0
- package/src/codex/role-sizing.ts +216 -0
- package/src/codex/routing/selection.ts +36 -0
- package/src/codex/routing/thread-affinity.ts +2 -0
- package/src/codex/routing.ts +4 -4
- package/src/codex/subagent-model-fallback.ts +82 -26
- package/src/codex/subagent-selectable-models.ts +32 -0
- package/src/combos/index.ts +18 -0
- package/src/combos/jev-decision-contract.ts +20 -0
- package/src/combos/jev-dispatch.ts +18 -0
- package/src/combos/jev-model-backend.ts +127 -0
- package/src/combos/jev.ts +249 -33
- package/src/combos/types.ts +125 -2
- package/src/config/diagnostics.ts +18 -0
- package/src/config/macos-system-proxy.ts +72 -15
- package/src/config/owner-registry.ts +13 -4
- package/src/config/process-state.ts +17 -8
- package/src/config/proxy-env.ts +35 -5
- package/src/config/schema/config-schema.ts +30 -1
- package/src/config/schema/leaf-validators.ts +4 -0
- package/src/config/serving-runtimes.ts +95 -1
- package/src/generated/compatibility-version.json +336 -156
- package/src/lib/errors.ts +7 -7
- package/src/lib/jsonc.ts +13 -0
- package/src/lib/local-chat-completion.ts +144 -0
- package/src/lib/provider-outbound.ts +87 -1
- package/src/lib/provider-runtime-fetch.ts +23 -0
- package/src/lib/provider-tls-profile.ts +221 -0
- package/src/lib/request-execution-budget.ts +16 -6
- package/src/lib/spend-reservation-ledger.ts +54 -20
- package/src/lib/sse-decoder.ts +20 -17
- package/src/lib/synced-state-location.ts +96 -0
- package/src/lib/winsw.ts +14 -5
- package/src/oauth/anthropic-account-refusal.ts +53 -4
- package/src/oauth/anthropic-model-quota.ts +83 -0
- package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
- package/src/oauth/anthropic-routing.ts +112 -58
- package/src/oauth/health.ts +39 -3
- package/src/oauth/index.ts +14 -0
- package/src/oauth/kiro-account-load.ts +10 -0
- package/src/oauth/zed.ts +134 -0
- package/src/providers/model-discovery.ts +19 -8
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/provider-id-rewrite.ts +9 -2
- package/src/providers/quota/account-cache.ts +29 -7
- package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
- package/src/providers/quota/anthropic-family-headers.ts +25 -0
- package/src/providers/quota/vendor-probes-oauth.ts +5 -1
- package/src/providers/quota-types.ts +4 -0
- package/src/providers/registry/entries-core.ts +18 -0
- package/src/providers/registry/entries-extended.ts +55 -0
- package/src/providers/registry/types.ts +3 -0
- package/src/providers/xai-fast-model.ts +12 -4
- package/src/providers/zed.ts +598 -0
- package/src/responses/state/snapshot-select.ts +22 -9
- package/src/responses/state.ts +2 -3
- package/src/router.ts +9 -0
- package/src/server/anthropic-rate-limit-headers.ts +7 -0
- package/src/server/auth-cors.ts +7 -0
- package/src/server/chat-completions.ts +9 -4
- package/src/server/claude-messages.ts +59 -21
- package/src/server/index/claude-intercept-lifecycle.ts +71 -25
- package/src/server/index/optional-listeners.ts +4 -0
- package/src/server/index/spend-ledger-lifecycle.ts +4 -0
- package/src/server/management/agent-settings-routes.ts +74 -37
- package/src/server/management/claude-desktop-picker-routes.ts +7 -0
- package/src/server/management/claude-intercept-routes.ts +35 -0
- package/src/server/management/codex-agent-role-routes.ts +122 -0
- package/src/server/management/codex-role-auto-assign.ts +208 -0
- package/src/server/management/combo-routes.ts +62 -5
- package/src/server/management/config-routes.ts +35 -3
- package/src/server/management/context.ts +4 -1
- package/src/server/management/decision-discovery.ts +74 -0
- package/src/server/management/decision-model-validation.ts +80 -0
- package/src/server/management/decision-routes.ts +160 -0
- package/src/server/management/native-integration-routes.ts +16 -0
- package/src/server/management/provider-routes.ts +30 -34
- package/src/server/management/route-registry.ts +12 -0
- package/src/server/management/sibling-guard.ts +1 -0
- package/src/server/management/zed-provider-probe.ts +38 -0
- package/src/server/management-api.ts +12 -1
- package/src/server/messages-native-oauth.ts +7 -2
- package/src/server/messages-native.ts +7 -3
- package/src/server/relay.ts +3 -1
- package/src/server/request-log.ts +2 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +18 -3
- package/src/server/responses/compact.ts +14 -1
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-combo.ts +67 -11
- package/src/server/responses/core-normalize.ts +21 -1
- package/src/server/responses/core-options.ts +11 -1
- package/src/server/responses/core.ts +5 -3
- package/src/server/responses/fetch-helpers.ts +6 -3
- package/src/server/responses/jev-model-invoke.ts +190 -0
- package/src/server/responses/passthrough-delivery.ts +16 -0
- package/src/server/responses/policy-fallback.ts +55 -3
- package/src/server/responses/policy-request-scope.ts +80 -0
- package/src/server/responses/request-prepare.ts +70 -18
- package/src/server/responses/request-transport.ts +54 -23
- package/src/server/responses/run-turn-execution.ts +1 -0
- package/src/server/responses/sidecar-execution.ts +9 -3
- package/src/server/responses-hosted-image-display.ts +225 -0
- package/src/service/managing-cli.ts +34 -4
- package/src/service/systemd-env.ts +75 -0
- package/src/service/windows-guarded-manager.ts +60 -19
- package/src/service/windows-scheduler.ts +22 -3
- package/src/service/windows-taskxml.ts +32 -10
- package/src/service-manager-probe.ts +72 -19
- package/src/types/config.ts +31 -1
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +2 -0
- package/src/update/index.ts +39 -6
- package/src/update/job.ts +63 -63
- package/src/update/npm-cache-preflight.d.mts +33 -1
- package/src/update/npm-cache-preflight.mjs +131 -18
- package/src/update/ownership-transaction.ts +19 -1
- package/src/update/restart-ownership.ts +80 -6
- package/src/update/transactional-install.d.mts +2 -0
- package/src/update/transactional-install.mjs +6 -1
- package/src/usage/jev-stats.ts +28 -1
- package/src/usage/log.ts +1 -1
- package/src/vision/anthropic-describe.ts +5 -3
- package/src/vision/index.ts +1 -0
- package/src/vision/plan.ts +2 -1
- package/src/vision/routed-describe.ts +29 -121
- package/src/web-search/alpha-search.ts +3 -3
- package/src/web-search/anthropic-executor.ts +6 -4
- package/src/web-search/index.ts +1 -1
- package/src/web-search/loop.ts +3 -3
- package/src/web-search/passthrough-bridge.ts +2 -1
- package/src/web-search/run-turn-loop.ts +1 -1
- package/src/web-search/sidecar-providers.ts +2 -1
- package/gui/dist/assets/App-ia4U0ufY.css +0 -1
- package/gui/dist/assets/App-xm0zGNOe.js +0 -51
- package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
- package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
- package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { anthropicFamilyRejected, anthropicFamilyRetryAt, anthropicModelExhausted, anthropicModelPercents, anthropicModelWeeklyPercent, clearAnthropicFamilyQuota } from "./anthropic-model-quota";
|
|
1
2
|
/**
|
|
2
3
|
* Opt-in Anthropic OAuth account pool (#294).
|
|
3
4
|
*
|
|
4
5
|
* Default OFF. When enabled:
|
|
5
6
|
* - Sticky session affinity across requests that share a session key
|
|
6
|
-
* -
|
|
7
|
+
* - Shared-quota 429s and classified pre-output account 403s cool the sender and fail over
|
|
8
|
+
* - Transient throttles preserve affinity; family refusals apply only to their model family
|
|
7
9
|
* - New sessions use `strategy` (default quota): lowest known fiveHour usage (#493),
|
|
8
10
|
* round-robin, or fill-first — affinity still wins for bound sessions
|
|
9
11
|
*
|
|
@@ -15,6 +17,7 @@
|
|
|
15
17
|
* backoff. Classified entitlement/billing 403s use a ten-minute default cooldown.
|
|
16
18
|
* Token refresh failures retain the existing store needsReauth policy.
|
|
17
19
|
*/
|
|
20
|
+
import { anthropicRatePauseUntil, clearAnthropicRatePauses, classifyAnthropic429, anthropicRetryAfterMs, pauseAnthropicRateAdmission } from "./anthropic-rate-limit-policy";
|
|
18
21
|
import { createHash } from "node:crypto";
|
|
19
22
|
import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store";
|
|
20
23
|
import type { OAuthAccessSnapshot } from "./index";
|
|
@@ -33,7 +36,7 @@ import {
|
|
|
33
36
|
import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConfig } from "../types";
|
|
34
37
|
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
35
38
|
import { retainedUtf8Bytes } from "../lib/admission";
|
|
36
|
-
import { routeCandidates, type AnthropicRouteDecision } from "./anthropic-model-routes";
|
|
39
|
+
import { resolveAnthropicModelRoute, routeCandidates, type AnthropicRouteDecision } from "./anthropic-model-routes";
|
|
37
40
|
import type { ProviderQuota } from "../providers/quota-types";
|
|
38
41
|
import {
|
|
39
42
|
anthropicCooldownGeneration,
|
|
@@ -185,6 +188,11 @@ function parseRateLimitReset(
|
|
|
185
188
|
if (delayUntil(resetAt, now) === undefined) continue;
|
|
186
189
|
if (latest === undefined || resetAt > latest) latest = resetAt;
|
|
187
190
|
}
|
|
191
|
+
if (latest === undefined && !rejectedQuotaWindows.length
|
|
192
|
+
&& headers.get("anthropic-ratelimit-unified-status")?.trim() === "rejected") {
|
|
193
|
+
const resetAt = Number(headers.get("anthropic-ratelimit-unified-reset")?.trim()) * 1000;
|
|
194
|
+
if (delayUntil(resetAt, now) !== undefined) latest = resetAt;
|
|
195
|
+
}
|
|
188
196
|
if (latest === undefined) return undefined;
|
|
189
197
|
return { delayMs: latest - now, rejectedQuotaWindows };
|
|
190
198
|
}
|
|
@@ -271,6 +279,8 @@ export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number {
|
|
|
271
279
|
/** Test / logout helper. */
|
|
272
280
|
export function clearAnthropicAccountPoolState(): void {
|
|
273
281
|
upstreamHealth.clear();
|
|
282
|
+
clearAnthropicRatePauses();
|
|
283
|
+
clearAnthropicFamilyQuota();
|
|
274
284
|
clearAnthropicCooldownGenerations();
|
|
275
285
|
sessionAffinity.clear();
|
|
276
286
|
manualPreference = undefined;
|
|
@@ -313,7 +323,8 @@ function exhausted5h(accountId: string): boolean {
|
|
|
313
323
|
return fiveHourKnown(accountId) && fiveHourScore(accountId) >= 100;
|
|
314
324
|
}
|
|
315
325
|
|
|
316
|
-
function hasKnownUsage(config: OcxConfig, accountId: string): boolean {
|
|
326
|
+
function hasKnownUsage(config: OcxConfig, accountId: string, model?: string): boolean {
|
|
327
|
+
if (model) return hasKnownUsage(config, accountId) || anthropicModelWeeklyPercent(getCachedProviderAccountQuota(PROVIDER, accountId), model) !== undefined;
|
|
317
328
|
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
318
329
|
switch (window) {
|
|
319
330
|
case "five-hour": return fiveHourKnown(accountId);
|
|
@@ -322,7 +333,12 @@ function hasKnownUsage(config: OcxConfig, accountId: string): boolean {
|
|
|
322
333
|
}
|
|
323
334
|
}
|
|
324
335
|
|
|
325
|
-
function usageScore(config: OcxConfig, accountId: string): number {
|
|
336
|
+
function usageScore(config: OcxConfig, accountId: string, model?: string): number {
|
|
337
|
+
if (model) {
|
|
338
|
+
const family = anthropicModelWeeklyPercent(getCachedProviderAccountQuota(PROVIDER, accountId), model);
|
|
339
|
+
if (family === undefined) return usageScore(config, accountId);
|
|
340
|
+
return hasKnownUsage(config, accountId) ? Math.max(family, usageScore(config, accountId)) : family;
|
|
341
|
+
}
|
|
326
342
|
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
327
343
|
switch (window) {
|
|
328
344
|
case "five-hour": return fiveHourScore(accountId);
|
|
@@ -348,13 +364,13 @@ function isPoolCredentialUsable(accountId: string, now: number): boolean {
|
|
|
348
364
|
return cred.expires > now + TOKEN_SKEW_MS;
|
|
349
365
|
}
|
|
350
366
|
|
|
351
|
-
export function getEligibleAnthropicAccounts(now = Date.now()): string[] {
|
|
367
|
+
export function getEligibleAnthropicAccounts(now = Date.now(), model?: string): string[] {
|
|
352
368
|
const set = getAccountSet(PROVIDER);
|
|
353
369
|
if (!set) return [];
|
|
354
370
|
return set.accounts
|
|
355
371
|
.filter(account =>
|
|
356
372
|
account.paused !== true && account.needsReauth !== true
|
|
357
|
-
&& !isCooled(account.id, now)
|
|
373
|
+
&& !isCooled(account.id, now) && !anthropicRatePauseUntil(account.id, now) && !anthropicFamilyRejected(account.id, model, now)
|
|
358
374
|
&& isPoolCredentialUsable(account.id, now))
|
|
359
375
|
.map(account => account.id);
|
|
360
376
|
}
|
|
@@ -452,20 +468,21 @@ export function forgetAnthropicFailoverQuorum(): void {
|
|
|
452
468
|
}
|
|
453
469
|
|
|
454
470
|
/** Earliest remaining cooldown among cooled Anthropic accounts, for client Retry-After. */
|
|
455
|
-
export function getAnthropicPoolRetryAfterSeconds(now = Date.now(), decision: AnthropicRouteDecision | null = null): number | null {
|
|
471
|
+
export function getAnthropicPoolRetryAfterSeconds(now = Date.now(), decision: AnthropicRouteDecision | null = null, model?: string): number | null {
|
|
456
472
|
const set = getAccountSet(PROVIDER);
|
|
457
473
|
if (!set) return null;
|
|
458
474
|
// Once an explicit fallback route has no eligible declared account, selection may use
|
|
459
475
|
// the ordinary pool. Its earliest usable cooldown must determine the advertised wait.
|
|
460
476
|
const fallbackExpanded = decision?.fallback === true
|
|
461
|
-
&& !getEligibleAnthropicAccounts(now).some(id => decision.accounts.includes(id));
|
|
477
|
+
&& !getEligibleAnthropicAccounts(now, model).some(id => decision.accounts.includes(id));
|
|
462
478
|
let earliest: number | null = null;
|
|
463
479
|
for (const account of set.accounts) {
|
|
464
480
|
if (account.paused === true || account.needsReauth === true || !isPoolCredentialUsable(account.id, now)) continue;
|
|
465
481
|
if (decision && !fallbackExpanded && !decision.accounts.includes(account.id)) continue;
|
|
466
482
|
const snap = getAnthropicAccountHealthSnapshot(account.id, now);
|
|
467
|
-
|
|
468
|
-
if (
|
|
483
|
+
const until = Math.max(snap?.cooldownUntil ?? 0, anthropicRatePauseUntil(account.id, now) ?? 0, anthropicFamilyRetryAt(account.id, model, now) ?? 0);
|
|
484
|
+
if (until <= now) continue;
|
|
485
|
+
if (earliest === null || until < earliest) earliest = until;
|
|
469
486
|
}
|
|
470
487
|
if (earliest === null || earliest <= now) return null;
|
|
471
488
|
return Math.max(1, Math.ceil((earliest - now) / 1000));
|
|
@@ -491,23 +508,24 @@ function compareScoredAccounts(a: ScoredAccount, b: ScoredAccount): number {
|
|
|
491
508
|
return a.score - b.score || a.fiveHourTieBreak - b.fiveHourTieBreak;
|
|
492
509
|
}
|
|
493
510
|
|
|
494
|
-
function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number, decision: AnthropicRouteDecision | null = null): string | null {
|
|
511
|
+
function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number, decision: AnthropicRouteDecision | null = null, model?: string): string | null {
|
|
495
512
|
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
496
|
-
const unfiltered = routeCandidates(getEligibleAnthropicAccounts(now), decision).filter(id => id !== excludeId);
|
|
513
|
+
const unfiltered = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId);
|
|
497
514
|
const available = window === "weekly" ? unfiltered.filter(id => !exhausted5h(id)
|
|
498
515
|
|| isAnthropicAccountPoolEnabled(config) && anthropicAccountAutoSwitchThreshold(config, id) === 0) : unfiltered;
|
|
499
|
-
const
|
|
516
|
+
const modelAvailable = model ? available.filter(id => !anthropicModelExhausted(id, model)) : available;
|
|
517
|
+
const availableOrFallback = modelAvailable.length > 0 ? modelAvailable : available.length > 0 ? available : unfiltered;
|
|
500
518
|
// Thresholds are preferences, never eligibility. Keep the old lowest-usage fallback
|
|
501
519
|
// when every candidate is drained, and keep pool-off reactive recovery policy inert.
|
|
502
520
|
const hasKnownUnderThreshold = isAnthropicAccountPoolEnabled(config)
|
|
503
|
-
&& availableOrFallback.some(id => hasKnownUsage(config, id) && isActiveUnderFillFirstThreshold(config, id));
|
|
521
|
+
&& availableOrFallback.some(id => hasKnownUsage(config, id, model) && isActiveUnderFillFirstThreshold(config, id, model));
|
|
504
522
|
const eligible = hasKnownUnderThreshold
|
|
505
|
-
? availableOrFallback.filter(id => isActiveUnderFillFirstThreshold(config, id)) : availableOrFallback;
|
|
523
|
+
? availableOrFallback.filter(id => isActiveUnderFillFirstThreshold(config, id, model)) : availableOrFallback;
|
|
506
524
|
if (eligible.length === 0) return null;
|
|
507
525
|
const scored: ScoredAccount[] = eligible.map(accountId => ({
|
|
508
526
|
accountId,
|
|
509
|
-
hasKnownUsage: hasKnownUsage(config, accountId),
|
|
510
|
-
score: usageScore(config, accountId),
|
|
527
|
+
hasKnownUsage: hasKnownUsage(config, accountId, model),
|
|
528
|
+
score: usageScore(config, accountId, model),
|
|
511
529
|
fiveHourTieBreak: window === "five-hour" ? 0 : fiveHourScore(accountId),
|
|
512
530
|
// Every window EXCEPT the legacy five-hour default is an explicit opt-in, so
|
|
513
531
|
// known-before-unknown applies to all of them and to none of the default path.
|
|
@@ -533,11 +551,13 @@ function pickNextFillFirstAnthropicAccount(
|
|
|
533
551
|
afterId: string,
|
|
534
552
|
eligible: string[],
|
|
535
553
|
decision: AnthropicRouteDecision | null,
|
|
554
|
+
model?: string,
|
|
536
555
|
): string | null {
|
|
537
556
|
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
538
557
|
const available = window === "weekly" ? eligible.filter(id => !exhausted5h(id)
|
|
539
558
|
|| anthropicAccountAutoSwitchThreshold(config, id) === 0) : eligible;
|
|
540
|
-
const
|
|
559
|
+
const modelAvailable = model ? available.filter(id => !anthropicModelExhausted(id, model)) : available;
|
|
560
|
+
const candidates = modelAvailable.length > 0 ? modelAvailable : available.length > 0 ? available : eligible;
|
|
541
561
|
if (candidates.length === 0) return null;
|
|
542
562
|
const routeOrder = usesDeclaredRouteOrder(eligible, decision);
|
|
543
563
|
const ordered = routeOrder ? candidates : [...candidates].sort((a, b) => a.localeCompare(b));
|
|
@@ -548,7 +568,7 @@ function pickNextFillFirstAnthropicAccount(
|
|
|
548
568
|
const startIdx = stableAll.indexOf(afterId);
|
|
549
569
|
if (startIdx < 0) {
|
|
550
570
|
for (const id of ordered) {
|
|
551
|
-
if (isActiveUnderFillFirstThreshold(config, id)) return id;
|
|
571
|
+
if (isActiveUnderFillFirstThreshold(config, id, model)) return id;
|
|
552
572
|
}
|
|
553
573
|
return ordered[0] ?? null;
|
|
554
574
|
}
|
|
@@ -558,26 +578,27 @@ function pickNextFillFirstAnthropicAccount(
|
|
|
558
578
|
const candidate = stableAll[(startIdx + step) % stableAll.length]!;
|
|
559
579
|
if (!candidates.includes(candidate)) continue;
|
|
560
580
|
if (!fallback) fallback = candidate;
|
|
561
|
-
if (isActiveUnderFillFirstThreshold(config, candidate)) return candidate;
|
|
581
|
+
if (isActiveUnderFillFirstThreshold(config, candidate, model)) return candidate;
|
|
562
582
|
}
|
|
563
583
|
return fallback ?? ordered[0] ?? null;
|
|
564
584
|
}
|
|
565
585
|
|
|
566
|
-
function pickAlternateAnthropicAccount(
|
|
586
|
+
export function pickAlternateAnthropicAccount(
|
|
567
587
|
config: OcxConfig,
|
|
568
588
|
excludeId: string,
|
|
569
589
|
now: number,
|
|
570
590
|
decision: AnthropicRouteDecision | null,
|
|
591
|
+
model?: string,
|
|
571
592
|
): string | null {
|
|
572
593
|
const strategy = anthropicPoolStrategy(config);
|
|
573
|
-
const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision).filter(id => id !== excludeId);
|
|
594
|
+
const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId);
|
|
574
595
|
if (strategy === "round-robin") {
|
|
575
596
|
return peekRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, stickyLimitForPool(config));
|
|
576
597
|
}
|
|
577
598
|
if (strategy === "fill-first") {
|
|
578
|
-
return pickNextFillFirstAnthropicAccount(config, excludeId, eligible, decision);
|
|
599
|
+
return pickNextFillFirstAnthropicAccount(config, excludeId, eligible, decision, model);
|
|
579
600
|
}
|
|
580
|
-
return pickLowestUsage(config, excludeId, now, decision);
|
|
601
|
+
return pickLowestUsage(config, excludeId, now, decision, model);
|
|
581
602
|
}
|
|
582
603
|
|
|
583
604
|
function pruneExpiredAffinity(now: number): void {
|
|
@@ -617,39 +638,39 @@ function anthropicPoolStrategy(config: OcxConfig): OcxAccountPoolRotationStrateg
|
|
|
617
638
|
return normalizeAccountPoolStrategy(anthropicAccountPoolConfig(config).strategy);
|
|
618
639
|
}
|
|
619
640
|
|
|
620
|
-
function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean {
|
|
641
|
+
function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string, model?: string): boolean {
|
|
621
642
|
const threshold = anthropicAccountAutoSwitchThreshold(config, accountId);
|
|
622
643
|
if (threshold <= 0) return true;
|
|
623
644
|
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
624
645
|
if (window === "weekly" && exhausted5h(accountId)) return false;
|
|
625
646
|
// Unknown usage must not force fill-first to abandon the active account.
|
|
626
|
-
if (!hasKnownUsage(config, accountId)) return true;
|
|
627
|
-
return usageScore(config, accountId) < threshold;
|
|
647
|
+
if (!hasKnownUsage(config, accountId, model)) return true;
|
|
648
|
+
return usageScore(config, accountId, model) < threshold;
|
|
628
649
|
}
|
|
629
650
|
|
|
630
651
|
/**
|
|
631
652
|
* Fill-first: keep eligible active under threshold; otherwise advance to the next
|
|
632
653
|
* eligible id in stable sorted order after the current active (wrapping).
|
|
633
654
|
*/
|
|
634
|
-
function pickFillFirstAnthropicAccount(config: OcxConfig, now: number, decision: AnthropicRouteDecision | null): string | null {
|
|
635
|
-
const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision);
|
|
655
|
+
function pickFillFirstAnthropicAccount(config: OcxConfig, now: number, decision: AnthropicRouteDecision | null, model?: string): string | null {
|
|
656
|
+
const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision);
|
|
636
657
|
if (eligible.length === 0) return null;
|
|
637
658
|
|
|
638
659
|
const set = getAccountSet(PROVIDER);
|
|
639
660
|
const active = set?.activeAccountId;
|
|
640
|
-
if (active && eligible.includes(active) && isActiveUnderFillFirstThreshold(config, active)) {
|
|
661
|
+
if (active && eligible.includes(active) && (!model || !anthropicModelExhausted(active, model)) && isActiveUnderFillFirstThreshold(config, active, model)) {
|
|
641
662
|
return active;
|
|
642
663
|
}
|
|
643
664
|
|
|
644
665
|
if (!active || !set) {
|
|
645
666
|
const ordered = usesDeclaredRouteOrder(eligible, decision) ? eligible : [...eligible].sort((a, b) => a.localeCompare(b));
|
|
646
667
|
for (const id of ordered) {
|
|
647
|
-
if (isActiveUnderFillFirstThreshold(config, id)) return id;
|
|
668
|
+
if (isActiveUnderFillFirstThreshold(config, id, model)) return id;
|
|
648
669
|
}
|
|
649
670
|
return ordered[0] ?? null;
|
|
650
671
|
}
|
|
651
672
|
|
|
652
|
-
return pickNextFillFirstAnthropicAccount(config, active, eligible, decision);
|
|
673
|
+
return pickNextFillFirstAnthropicAccount(config, active, eligible, decision, model);
|
|
653
674
|
}
|
|
654
675
|
|
|
655
676
|
/**
|
|
@@ -660,20 +681,22 @@ function pickUnboundStrategyAccount(
|
|
|
660
681
|
config: OcxConfig,
|
|
661
682
|
now: number,
|
|
662
683
|
decision: AnthropicRouteDecision | null,
|
|
684
|
+
model?: string,
|
|
663
685
|
): { accountId: string; reason: "round-robin" | "fill-first" } | null {
|
|
664
686
|
const strategy = anthropicPoolStrategy(config);
|
|
665
687
|
if (strategy === "quota") return null;
|
|
666
688
|
|
|
667
689
|
if (strategy === "round-robin") {
|
|
668
|
-
const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision);
|
|
690
|
+
const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision);
|
|
669
691
|
const limit = stickyLimitForPool(config);
|
|
670
|
-
const
|
|
692
|
+
const headroom = model ? eligible.filter(id => !anthropicModelExhausted(id, model)) : eligible;
|
|
693
|
+
const picked = peekRoundRobinAccount(POOL_KEY_ANTHROPIC, headroom.length ? headroom : eligible, limit);
|
|
671
694
|
if (!picked) return null;
|
|
672
695
|
return { accountId: picked, reason: "round-robin" };
|
|
673
696
|
}
|
|
674
697
|
|
|
675
698
|
if (strategy === "fill-first") {
|
|
676
|
-
const picked = pickFillFirstAnthropicAccount(config, now, decision);
|
|
699
|
+
const picked = pickFillFirstAnthropicAccount(config, now, decision, model);
|
|
677
700
|
if (!picked) return null;
|
|
678
701
|
return { accountId: picked, reason: "fill-first" };
|
|
679
702
|
}
|
|
@@ -690,6 +713,7 @@ export function resolveAnthropicAccountForSession(
|
|
|
690
713
|
config: OcxConfig,
|
|
691
714
|
now = Date.now(),
|
|
692
715
|
decision: AnthropicRouteDecision | null = null,
|
|
716
|
+
model?: string,
|
|
693
717
|
): AnthropicAccountSelection {
|
|
694
718
|
pruneExpiredAffinity(now);
|
|
695
719
|
const set = getAccountSet(PROVIDER);
|
|
@@ -706,21 +730,21 @@ export function resolveAnthropicAccountForSession(
|
|
|
706
730
|
: null;
|
|
707
731
|
}
|
|
708
732
|
|
|
709
|
-
const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision);
|
|
733
|
+
const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision);
|
|
710
734
|
if (eligible.length === 0) {
|
|
711
735
|
// Pause, removal and reauthentication are not cooldown evidence. Classify only
|
|
712
736
|
// usable members of this strict route (or the ordinary pool after fallback widens).
|
|
713
737
|
const recoverable = set.accounts.filter(account =>
|
|
714
738
|
(!decision || decision.fallback || decision.accounts.includes(account.id))
|
|
715
739
|
&& account.paused !== true && account.needsReauth !== true && isPoolCredentialUsable(account.id, now));
|
|
716
|
-
const cooled = recoverable.length > 0 && recoverable.every(account => isCooled(account.id, now));
|
|
740
|
+
const cooled = recoverable.length > 0 && recoverable.every(account => isCooled(account.id, now) || anthropicRatePauseUntil(account.id, now) || anthropicFamilyRejected(account.id, model, now));
|
|
717
741
|
if (cooled || decision) return { accountId: null, reason: cooled ? "all-cooled" : "none", routePosition: decision?.position };
|
|
718
742
|
}
|
|
719
743
|
|
|
720
744
|
if (!isAnthropicAccountPoolEnabled(config)) {
|
|
721
745
|
const active = set.accounts.find(account => account.id === set.activeAccountId);
|
|
722
746
|
// Disabled proactive rotation does not authorize a paused slot or an entirely cooled pool.
|
|
723
|
-
if (active?.paused || isCooled(set.activeAccountId, now)) {
|
|
747
|
+
if (active?.paused || isCooled(set.activeAccountId, now) || anthropicRatePauseUntil(set.activeAccountId, now) || anthropicFamilyRejected(set.activeAccountId, model, now)) {
|
|
724
748
|
return { accountId: eligible[0] ?? null, reason: eligible.length > 0 ? "only-eligible" : "none" };
|
|
725
749
|
}
|
|
726
750
|
return { accountId: set.activeAccountId, reason: "pool-disabled" };
|
|
@@ -734,9 +758,7 @@ export function resolveAnthropicAccountForSession(
|
|
|
734
758
|
} else {
|
|
735
759
|
const chosen = manualPreference.accountId;
|
|
736
760
|
const quota = getCachedProviderAccountQuota(PROVIDER, chosen);
|
|
737
|
-
const exhausted =
|
|
738
|
-
...(quota?.customWindows ?? []).map(window => window.percent)]
|
|
739
|
-
.some(percent => typeof percent === "number" && percent >= 100);
|
|
761
|
+
const exhausted = anthropicModelPercents(quota, model).some(percent => percent >= 100);
|
|
740
762
|
if (!exhausted && eligible.includes(chosen)) {
|
|
741
763
|
return { accountId: chosen, reason: "manual", routePosition: decision?.position };
|
|
742
764
|
}
|
|
@@ -750,7 +772,7 @@ export function resolveAnthropicAccountForSession(
|
|
|
750
772
|
const stillThere = set.accounts.some(a => a.id === affined.accountId && a.paused !== true && a.needsReauth !== true);
|
|
751
773
|
const stillUsable = stillThere && !isCooled(affined.accountId, now)
|
|
752
774
|
&& isPoolCredentialUsable(affined.accountId, now);
|
|
753
|
-
if (stillUsable && eligible.includes(affined.accountId)) {
|
|
775
|
+
if (stillUsable && eligible.includes(affined.accountId) && (!model || !anthropicModelExhausted(affined.accountId, model))) {
|
|
754
776
|
return { accountId: affined.accountId, reason: "affinity", routePosition: decision?.position };
|
|
755
777
|
}
|
|
756
778
|
// A model route may exclude a healthy binding only for this request. Keep it for
|
|
@@ -766,13 +788,13 @@ export function resolveAnthropicAccountForSession(
|
|
|
766
788
|
if (!key && (strategy === "round-robin" || strategy === "fill-first")) {
|
|
767
789
|
const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true)
|
|
768
790
|
&& !isCooled(set.activeAccountId, now)
|
|
769
|
-
&& eligible.includes(set.activeAccountId);
|
|
791
|
+
&& eligible.includes(set.activeAccountId) && (!model || !anthropicModelExhausted(set.activeAccountId, model));
|
|
770
792
|
if (activeOk) {
|
|
771
793
|
return { accountId: set.activeAccountId, reason: "active", routePosition: decision?.position };
|
|
772
794
|
}
|
|
773
795
|
}
|
|
774
796
|
|
|
775
|
-
const strategyPick = pickUnboundStrategyAccount(config, now, decision);
|
|
797
|
+
const strategyPick = pickUnboundStrategyAccount(config, now, decision, model);
|
|
776
798
|
if (strategyPick) {
|
|
777
799
|
return { accountId: strategyPick.accountId, reason: strategyPick.reason, routePosition: decision?.position };
|
|
778
800
|
}
|
|
@@ -780,7 +802,7 @@ export function resolveAnthropicAccountForSession(
|
|
|
780
802
|
const threshold = anthropicAccountAutoSwitchThreshold(config, set.activeAccountId);
|
|
781
803
|
const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true)
|
|
782
804
|
&& !isCooled(set.activeAccountId, now)
|
|
783
|
-
&& eligible.includes(set.activeAccountId);
|
|
805
|
+
&& eligible.includes(set.activeAccountId) && (!model || !anthropicModelExhausted(set.activeAccountId, model));
|
|
784
806
|
|
|
785
807
|
let accountId: string | null = null;
|
|
786
808
|
let reason: AnthropicAccountSelectionReason = "none";
|
|
@@ -790,11 +812,11 @@ export function resolveAnthropicAccountForSession(
|
|
|
790
812
|
// Unknown usage must NOT force a switch away from the healthy active account.
|
|
791
813
|
if (activeOk
|
|
792
814
|
&& !(window === "weekly" && exhausted5h(set.activeAccountId))
|
|
793
|
-
&& (!hasKnownUsage(config, set.activeAccountId) || usageScore(config, set.activeAccountId) < threshold)) {
|
|
815
|
+
&& (!hasKnownUsage(config, set.activeAccountId, model) || usageScore(config, set.activeAccountId, model) < threshold)) {
|
|
794
816
|
accountId = set.activeAccountId;
|
|
795
817
|
reason = "active";
|
|
796
818
|
} else {
|
|
797
|
-
const picked = pickLowestUsage(config, undefined, now, decision);
|
|
819
|
+
const picked = pickLowestUsage(config, undefined, now, decision, model);
|
|
798
820
|
if (picked) {
|
|
799
821
|
accountId = picked;
|
|
800
822
|
reason = activeOk && picked === set.activeAccountId ? "active" : "lowest-usage";
|
|
@@ -807,7 +829,7 @@ export function resolveAnthropicAccountForSession(
|
|
|
807
829
|
accountId = set.activeAccountId;
|
|
808
830
|
reason = "active";
|
|
809
831
|
} else {
|
|
810
|
-
const picked = pickLowestUsage(config, set.activeAccountId, now, decision);
|
|
832
|
+
const picked = pickLowestUsage(config, set.activeAccountId, now, decision, model);
|
|
811
833
|
if (picked) {
|
|
812
834
|
accountId = picked;
|
|
813
835
|
reason = "only-eligible";
|
|
@@ -827,13 +849,14 @@ export async function resolveAnthropicDispatchAccountId(
|
|
|
827
849
|
config: OcxConfig,
|
|
828
850
|
sessionKey: string | null = null,
|
|
829
851
|
decision: AnthropicRouteDecision | null = null,
|
|
852
|
+
model?: string,
|
|
830
853
|
): Promise<string> {
|
|
831
854
|
const now = Date.now();
|
|
832
|
-
const selection = resolveAnthropicAccountForSession(sessionKey, config, now, decision);
|
|
855
|
+
const selection = resolveAnthropicAccountForSession(sessionKey, config, now, decision, model);
|
|
833
856
|
if (selection.reason === "all-cooled") {
|
|
834
|
-
throw new AnthropicAccountCooldownError(getAnthropicPoolRetryAfterSeconds(now, decision), decision?.position);
|
|
857
|
+
throw new AnthropicAccountCooldownError(getAnthropicPoolRetryAfterSeconds(now, decision, model), decision?.position);
|
|
835
858
|
}
|
|
836
|
-
if (selection.reason === "paused" || !selection.accountId || !getEligibleAnthropicAccounts(now).includes(selection.accountId)) {
|
|
859
|
+
if (selection.reason === "paused" || !selection.accountId || !getEligibleAnthropicAccounts(now, model).includes(selection.accountId)) {
|
|
837
860
|
const { OAuthAccountPausedError, OAuthLoginRequiredError } = await import("./index");
|
|
838
861
|
const active = getAccountSet(PROVIDER)?.activeAccountId;
|
|
839
862
|
// Pool-off fresh admission resolves the active credential first. Preserve that
|
|
@@ -879,8 +902,9 @@ export function rotateAnthropicAccountOn429(
|
|
|
879
902
|
now = Date.now(),
|
|
880
903
|
rateLimitHeaders?: AnthropicRateLimitHeaders | null,
|
|
881
904
|
decision: AnthropicRouteDecision | null = null,
|
|
905
|
+
model?: string,
|
|
882
906
|
): string | null {
|
|
883
|
-
return rotateAnthropicAccountOnRefusal(config, failedAccountId, 429, retryAfterHeader, sessionKey, now, rateLimitHeaders, decision);
|
|
907
|
+
return rotateAnthropicAccountOnRefusal(config, failedAccountId, 429, retryAfterHeader, sessionKey, now, rateLimitHeaders, decision, model);
|
|
884
908
|
}
|
|
885
909
|
|
|
886
910
|
/** The caller proves that a 403 is an account refusal before entering this pool policy. */
|
|
@@ -893,6 +917,7 @@ export function rotateAnthropicAccountOnRefusal(
|
|
|
893
917
|
now = Date.now(),
|
|
894
918
|
rateLimitHeaders?: AnthropicRateLimitHeaders | null,
|
|
895
919
|
decision: AnthropicRouteDecision | null = null,
|
|
920
|
+
model?: string,
|
|
896
921
|
): string | null {
|
|
897
922
|
if (!recordAnthropicAccountRefusal(config, failedAccountId, status, retryAfterHeader, now, rateLimitHeaders)) return null;
|
|
898
923
|
|
|
@@ -901,8 +926,8 @@ export function rotateAnthropicAccountOnRefusal(
|
|
|
901
926
|
// because those dormant values remain in config. The quota picker is the neutral
|
|
902
927
|
// recovery policy already used by the default strategy.
|
|
903
928
|
const next = isAnthropicAccountPoolEnabled(config)
|
|
904
|
-
? pickAlternateAnthropicAccount(config, failedAccountId, now, decision)
|
|
905
|
-
: pickLowestUsage(config, failedAccountId, now);
|
|
929
|
+
? pickAlternateAnthropicAccount(config, failedAccountId, now, decision, model)
|
|
930
|
+
: pickLowestUsage(config, failedAccountId, now, null, model);
|
|
906
931
|
if (!next) {
|
|
907
932
|
console.warn(`[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}no eligible replacement; returning ${status}`);
|
|
908
933
|
return null;
|
|
@@ -946,6 +971,15 @@ export function recordAnthropicAccountRefusal(
|
|
|
946
971
|
if (!isAnthropicAccountPoolEnabled(config) && !hasAnthropicFailoverQuorum(now)
|
|
947
972
|
&& !getAccountCredentialWithStatus(PROVIDER, failedAccountId)?.paused) return false;
|
|
948
973
|
|
|
974
|
+
if (status === 429) {
|
|
975
|
+
const headers = rateLimitHeaders ?? new Headers();
|
|
976
|
+
const kind = classifyAnthropic429({ get: name => name === "retry-after" ? retryAfterHeader ?? null : headers.get(name) }, now);
|
|
977
|
+
if (kind !== "shared-quota") {
|
|
978
|
+
if (kind === "transient-rate") pauseAnthropicRateAdmission(failedAccountId, now + (anthropicRetryAfterMs(retryAfterHeader, now) ?? 100));
|
|
979
|
+
return false;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
949
983
|
// Retry-After first: it is the header written FOR this decision. The rejected window's
|
|
950
984
|
// reset is the fallback, because a 429 that omits Retry-After still carries it -- and
|
|
951
985
|
// without that fallback such a refusal cools for the 60s default and the exhausted
|
|
@@ -977,6 +1011,7 @@ export interface AnthropicSelectionRoutingOptions {
|
|
|
977
1011
|
reason?: AnthropicAccountSelectionReason;
|
|
978
1012
|
expectedCredentialGeneration?: string;
|
|
979
1013
|
routeDecision?: AnthropicRouteDecision | null;
|
|
1014
|
+
model?: string;
|
|
980
1015
|
}
|
|
981
1016
|
|
|
982
1017
|
/** Commit the selected account before dispatch; rejected proposals have no routing side effects. */
|
|
@@ -985,7 +1020,7 @@ export async function promoteAnthropicActiveAccount(
|
|
|
985
1020
|
expectedSelection: OAuthAccountSelection | null,
|
|
986
1021
|
options: AnthropicSelectionRoutingOptions,
|
|
987
1022
|
): Promise<OAuthAccountSelection | null> {
|
|
988
|
-
if (!expectedSelection || !routeCandidates(getEligibleAnthropicAccounts(), options.routeDecision ?? null).includes(accountId)) return null;
|
|
1023
|
+
if (!expectedSelection || !routeCandidates(getEligibleAnthropicAccounts(Date.now(), options.model), options.routeDecision ?? null).includes(accountId)) return null;
|
|
989
1024
|
const committed = await commitOAuthAccountSelection(PROVIDER, accountId, {
|
|
990
1025
|
expectedSelection,
|
|
991
1026
|
expectedCredentialGeneration: options.expectedCredentialGeneration,
|
|
@@ -1003,13 +1038,13 @@ export function commitAnthropicSelectionRouting(
|
|
|
1003
1038
|
options: AnthropicSelectionRoutingOptions,
|
|
1004
1039
|
): boolean {
|
|
1005
1040
|
if (getAccountCredentialWithStatus(PROVIDER, accountId)?.paused || committed.accountId !== accountId || (options.routeDecision
|
|
1006
|
-
&& !routeCandidates(getEligibleAnthropicAccounts(), options.routeDecision).includes(accountId))) return false;
|
|
1041
|
+
&& !routeCandidates(getEligibleAnthropicAccounts(Date.now(), options.model), options.routeDecision).includes(accountId))) return false;
|
|
1007
1042
|
const current = captureOAuthAccountSelection(PROVIDER);
|
|
1008
1043
|
if (current?.accountId !== committed.accountId || current.revision !== committed.revision) return false;
|
|
1009
1044
|
if (isAnthropicAccountPoolEnabled(options.config)) {
|
|
1010
1045
|
if (anthropicPoolStrategy(options.config) === "round-robin" && options.reason !== "affinity") {
|
|
1011
1046
|
const limit = stickyLimitForPool(options.config);
|
|
1012
|
-
const picked = pickRoundRobinAccount(POOL_KEY_ANTHROPIC, routeCandidates(getEligibleAnthropicAccounts(), options.routeDecision ?? null), limit);
|
|
1047
|
+
const picked = pickRoundRobinAccount(POOL_KEY_ANTHROPIC, routeCandidates(getEligibleAnthropicAccounts(Date.now(), options.model), options.routeDecision ?? null), limit);
|
|
1013
1048
|
if (picked !== accountId) seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId);
|
|
1014
1049
|
notePoolRotationSuccess(POOL_KEY_ANTHROPIC, accountId, limit);
|
|
1015
1050
|
}
|
|
@@ -1020,7 +1055,9 @@ export function commitAnthropicSelectionRouting(
|
|
|
1020
1055
|
&& Date.now() - bound.lastUsedAt <= AFFINITY_IDLE_TTL_MS
|
|
1021
1056
|
&& eligibleAtCommit.includes(bound.accountId)
|
|
1022
1057
|
&& !routeCandidates(eligibleAtCommit, options.routeDecision).includes(bound.accountId);
|
|
1023
|
-
|
|
1058
|
+
const preservePausedAffinity = bound && (anthropicRatePauseUntil(bound.accountId) !== undefined
|
|
1059
|
+
|| anthropicFamilyRejected(bound.accountId, options.model));
|
|
1060
|
+
if (!preserveExcludedAffinity && !preservePausedAffinity) bindAnthropicSessionAffinity(options.sessionKey, accountId);
|
|
1024
1061
|
}
|
|
1025
1062
|
if (manualPreference === undefined || (manualPreference?.accountId === expectedSelection.accountId
|
|
1026
1063
|
&& manualPreference.revision === expectedSelection.revision)) manualPreference = null;
|
|
@@ -1079,6 +1116,23 @@ export async function getAnthropicPoolAccessSnapshot(accountId: string): Promise
|
|
|
1079
1116
|
return { provider: PROVIDER, accountId, accessToken, generation: credentialGeneration(row.credential) };
|
|
1080
1117
|
}
|
|
1081
1118
|
|
|
1119
|
+
/** Resolve a direct Anthropic helper send through the same model route as primary traffic. */
|
|
1120
|
+
export async function getAnthropicSidecarAccessToken(
|
|
1121
|
+
providerName: string,
|
|
1122
|
+
model: string,
|
|
1123
|
+
config?: OcxConfig,
|
|
1124
|
+
): Promise<string> {
|
|
1125
|
+
if (providerName !== PROVIDER || !config || !isAnthropicAccountPoolEnabled(config)) {
|
|
1126
|
+
const { getValidAccessToken } = await import("./index");
|
|
1127
|
+
return getValidAccessToken(providerName);
|
|
1128
|
+
}
|
|
1129
|
+
const route = resolveAnthropicModelRoute(config, model);
|
|
1130
|
+
if (route.error) throw new Error("Invalid Anthropic model route configuration");
|
|
1131
|
+
const selection = resolveAnthropicAccountForSession(null, config, Date.now(), route.decision, model);
|
|
1132
|
+
if (!selection.accountId) throw new Error("No permitted Anthropic account is available for this model");
|
|
1133
|
+
return (await getAnthropicPoolAccessSnapshot(selection.accountId)).accessToken;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1082
1136
|
/**
|
|
1083
1137
|
* Whether the pool may refresh this account's token. Background `local-cli` slots must not
|
|
1084
1138
|
* adopt the global Claude CLI credential (same fail-closed rule as quota probes).
|
package/src/oauth/health.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { MainAccountHardLockStatus } from "../codex/main-account-hard-lock";
|
|
2
|
+
import type { MainAccountExternalUsageWarning } from "../codex/main-account-external-usage";
|
|
1
3
|
import { getCodexAccountHealthSnapshot, type CodexCooldownSource } from "../codex/routing";
|
|
2
4
|
import { getAnthropicAccountHealthSnapshot } from "./anthropic-routing";
|
|
3
5
|
import { isAccountNeedsReauth } from "../codex/account-runtime-state";
|
|
@@ -353,7 +355,10 @@ export function collectOAuthHealthEntries(
|
|
|
353
355
|
return entries;
|
|
354
356
|
}
|
|
355
357
|
|
|
358
|
+
export type CodexMainAccountPolicyHealth = MainAccountHardLockStatus & { externalUsage?: MainAccountExternalUsageWarning };
|
|
359
|
+
|
|
356
360
|
type ProxyCodexAccountHealth = {
|
|
361
|
+
mainAccountHardLock?: unknown;
|
|
357
362
|
id: string;
|
|
358
363
|
health?: OAuthAccountHealth;
|
|
359
364
|
needsReauth?: boolean;
|
|
@@ -372,11 +377,37 @@ function coerceRemoteAccountHealth(
|
|
|
372
377
|
}
|
|
373
378
|
|
|
374
379
|
type LiveProxyCodexHealthResult = {
|
|
380
|
+
mainAccountHardLock?: CodexMainAccountPolicyHealth;
|
|
375
381
|
source: CodexHealthSource;
|
|
376
382
|
entries: OAuthHealthEntry[] | null;
|
|
377
383
|
};
|
|
378
384
|
|
|
379
|
-
|
|
385
|
+
/** Whitelisted projection: never forward account metadata or arbitrary management fields. */
|
|
386
|
+
export function projectMainAccountPolicyHealth(value: unknown): CodexMainAccountPolicyHealth | undefined {
|
|
387
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
388
|
+
const raw = value as Record<string, unknown>;
|
|
389
|
+
const thresholds = raw.thresholds as { short?: unknown; long?: unknown } | undefined;
|
|
390
|
+
const percent = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 100;
|
|
391
|
+
if (typeof raw.enabled !== "boolean" || typeof raw.state !== "string"
|
|
392
|
+
|| !(raw.enabled ? ["unknown", "ready", "blocked"] : ["off"]).includes(raw.state)
|
|
393
|
+
|| !thresholds || !percent(thresholds.short) || !percent(thresholds.long)
|
|
394
|
+
|| !Number.isInteger(thresholds.short) || !Number.isInteger(thresholds.long)
|
|
395
|
+
|| thresholds.short < 80 || thresholds.short > thresholds.long) return undefined;
|
|
396
|
+
const result: CodexMainAccountPolicyHealth = { enabled: raw.enabled,
|
|
397
|
+
state: raw.state as MainAccountHardLockStatus["state"], thresholds: { short: thresholds.short, long: thresholds.long } };
|
|
398
|
+
if (raw.window === "short" || raw.window === "long") result.window = raw.window;
|
|
399
|
+
if (typeof raw.resetAt === "number" && Number.isFinite(raw.resetAt)) result.resetAt = raw.resetAt;
|
|
400
|
+
const warning = raw.externalUsage as Partial<MainAccountExternalUsageWarning> | undefined;
|
|
401
|
+
if (warning && (warning.window === "short" || warning.window === "long")
|
|
402
|
+
&& percent(warning.fromPercent) && percent(warning.toPercent)
|
|
403
|
+
&& typeof warning.observedAt === "number" && Number.isFinite(warning.observedAt)) {
|
|
404
|
+
result.externalUsage = { window: warning.window, fromPercent: warning.fromPercent,
|
|
405
|
+
toPercent: warning.toPercent, observedAt: warning.observedAt };
|
|
406
|
+
}
|
|
407
|
+
return result;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function fetchCodexHealthFromLiveProxy(
|
|
380
411
|
fetchImpl: typeof fetch | undefined = undefined,
|
|
381
412
|
findLiveProxyImpl: typeof findLiveProxy = findLiveProxy,
|
|
382
413
|
readRuntimePortImpl: typeof readRuntimePort = readRuntimePort,
|
|
@@ -404,7 +435,10 @@ async function fetchCodexHealthFromLiveProxy(
|
|
|
404
435
|
if (!account?.id || typeof account.id !== "string") continue;
|
|
405
436
|
pushEntry(entries, "codex", account.id, coerceRemoteAccountHealth(account));
|
|
406
437
|
}
|
|
407
|
-
|
|
438
|
+
const mainAccountHardLock = projectMainAccountPolicyHealth(
|
|
439
|
+
json.accounts.find(account => account?.id === MAIN_CODEX_ACCOUNT_ID)?.mainAccountHardLock,
|
|
440
|
+
);
|
|
441
|
+
return { source: "management-api", entries, ...(mainAccountHardLock ? { mainAccountHardLock } : {}) };
|
|
408
442
|
} catch {
|
|
409
443
|
return { source: "management-api-unavailable", entries: null };
|
|
410
444
|
}
|
|
@@ -418,6 +452,7 @@ export type CodexHealthSource =
|
|
|
418
452
|
| "management-api-unavailable";
|
|
419
453
|
|
|
420
454
|
export type OAuthCliHealthReport = {
|
|
455
|
+
mainAccountHardLock?: CodexMainAccountPolicyHealth;
|
|
421
456
|
entries: OAuthHealthEntry[];
|
|
422
457
|
codexHealthSource: CodexHealthSource;
|
|
423
458
|
};
|
|
@@ -450,7 +485,8 @@ export async function collectOAuthHealthEntriesForCli(
|
|
|
450
485
|
);
|
|
451
486
|
if (remote.entries) {
|
|
452
487
|
for (const entry of remote.entries) entries.push(entry);
|
|
453
|
-
return { entries, codexHealthSource: "management-api"
|
|
488
|
+
return { entries, codexHealthSource: "management-api",
|
|
489
|
+
...(remote.mainAccountHardLock ? { mainAccountHardLock: remote.mainAccountHardLock } : {}) };
|
|
454
490
|
}
|
|
455
491
|
return { entries, codexHealthSource: remote.source };
|
|
456
492
|
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, Re
|
|
|
39
39
|
import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt";
|
|
40
40
|
import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
|
|
41
41
|
import { loginCursor, refreshCursorToken } from "./cursor";
|
|
42
|
+
import { loginZed, refreshZedToken } from "./zed";
|
|
42
43
|
import { assertDevinCliAdoptionOwnership, loginDevin, refreshDevinToken } from "./devin";
|
|
43
44
|
import { validateDevinApiBaseUrl } from "./devin/api-base";
|
|
44
45
|
import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
|
|
@@ -100,6 +101,11 @@ export interface OAuthAccessSnapshot {
|
|
|
100
101
|
* concurrent switch (#2568d).
|
|
101
102
|
*/
|
|
102
103
|
apiBaseUrl?: string;
|
|
104
|
+
/**
|
|
105
|
+
* The upstream's own user id for providers that sign requests with it (Zed's `user_id`).
|
|
106
|
+
* `accountId` is the local store slot key, a hash, and must never stand in for it.
|
|
107
|
+
*/
|
|
108
|
+
providerUserId?: string;
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
export interface ObservedOAuthAccessSnapshot extends OAuthAccessSnapshot {
|
|
@@ -319,6 +325,13 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
|
|
|
319
325
|
providerConfig: oauthConfig("cursor"),
|
|
320
326
|
defaultModel: oauthDefaultModel("cursor"),
|
|
321
327
|
},
|
|
328
|
+
zed: {
|
|
329
|
+
login: ctrl => loginZed(ctrl),
|
|
330
|
+
refresh: refreshZedToken,
|
|
331
|
+
providerConfig: oauthConfig("zed"),
|
|
332
|
+
defaultModel: oauthDefaultModel("zed"),
|
|
333
|
+
defaultRefreshPolicy: "disabled",
|
|
334
|
+
},
|
|
322
335
|
devin: {
|
|
323
336
|
// Import-first: adopts a signed-in Devin CLI credential when one exists and
|
|
324
337
|
// only then falls back to the Auth0 browser flow. forceLogin skips the
|
|
@@ -511,6 +524,7 @@ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredenti
|
|
|
511
524
|
accessToken: cred.access,
|
|
512
525
|
...(cred.projectId ? { projectId: cred.projectId } : {}),
|
|
513
526
|
...(accountApiBaseUrl ? { apiBaseUrl: accountApiBaseUrl } : {}),
|
|
527
|
+
...(oauthProvider === "zed" && cred.accountId ? { providerUserId: cred.accountId } : {}),
|
|
514
528
|
// Stored account metadata remains authoritative. Metadata-less legacy/environment credentials
|
|
515
529
|
// may use explicit environment routing, but never borrow the currently signed-in local CLI account.
|
|
516
530
|
...(provider === "kiro"
|
|
@@ -21,6 +21,15 @@ interface AccountState { records: Map<number, LeaseRecord>; waiters: Waiter[] }
|
|
|
21
21
|
const accounts = new Map<string, AccountState>();
|
|
22
22
|
let nextLeaseId = 0;
|
|
23
23
|
const keyOf = (provider: string, accountId: string) => `${provider}\u0000${accountId}`;
|
|
24
|
+
type QueuedHook = (provider: string, accountId: string) => void;
|
|
25
|
+
let queuedHookForTests: QueuedHook | undefined;
|
|
26
|
+
|
|
27
|
+
/** Observe capacity admission without replacing the real lease handoff. */
|
|
28
|
+
export function observeQueuedAccountLeaseForTests(hook: QueuedHook): () => void {
|
|
29
|
+
const previous = queuedHookForTests;
|
|
30
|
+
queuedHookForTests = hook;
|
|
31
|
+
return () => { queuedHookForTests = previous; };
|
|
32
|
+
}
|
|
24
33
|
|
|
25
34
|
function handoff(state: AccountState, now: number): void {
|
|
26
35
|
while (state.waiters.length > 0) {
|
|
@@ -103,5 +112,6 @@ export async function acquireAccountLease(
|
|
|
103
112
|
const timer = setTimeout(() => finish(null), remaining);
|
|
104
113
|
opts.signal?.addEventListener("abort", abort, { once: true });
|
|
105
114
|
if (opts.signal?.aborted) finish(null);
|
|
115
|
+
if (!settled) queuedHookForTests?.(provider, accountId);
|
|
106
116
|
});
|
|
107
117
|
}
|