@bitkyc08/opencodex 2.43.0 → 2.44.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 +25 -17
- package/gui/dist/assets/index-B7_K1Hsj.js +115 -0
- package/gui/dist/assets/index-ltx3L-WS.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +23 -2
- package/src/adapters/cursor/native-exec.ts +63 -11
- package/src/adapters/cursor/tool-definitions.ts +1 -1
- package/src/adapters/cursor/tool-schemas.ts +59 -0
- package/src/adapters/kiro.ts +54 -8
- package/src/adapters/openai-chat.ts +23 -6
- package/src/adapters/openai-responses.ts +3 -1
- package/src/adapters/opencode-go.ts +35 -0
- package/src/claude/agents-inject.ts +12 -5
- package/src/claude/desktop-3p-library.ts +89 -0
- package/src/claude/desktop-3p.ts +166 -95
- package/src/claude/desktop-discovery-inputs.ts +44 -0
- package/src/claude/desktop-profile.ts +1 -1
- package/src/claude/desktop-remote-store-artifact.ts +185 -0
- package/src/claude/desktop-remote-store-io.ts +91 -0
- package/src/claude/desktop-remote-store-state.ts +127 -0
- package/src/claude/desktop-remote-store.ts +269 -0
- package/src/claude/inbound-model-options.ts +10 -2
- package/src/claude/inbound-records.ts +7 -0
- package/src/claude/inbound.ts +1 -1
- package/src/claude/model-info.ts +16 -3
- package/src/cli/account-api.ts +7 -1
- package/src/cli/aside-profiles.ts +17 -0
- package/src/cli/capabilities.ts +39 -0
- package/src/cli/claude-agent-startup-sync.ts +3 -1
- package/src/cli/claude-desktop.ts +127 -18
- package/src/cli/connect.ts +27 -14
- package/src/cli/dispatch.ts +30 -16
- package/src/cli/export-command.ts +3 -1
- package/src/cli/index.ts +28 -16
- package/src/cli/integrations.ts +46 -12
- package/src/cli/opencode.ts +11 -4
- package/src/cli/runtime-api.ts +7 -2
- package/src/cli/uninstall-client-state.ts +78 -0
- package/src/client/connect.ts +476 -223
- package/src/client/hub-client.ts +81 -0
- package/src/client/hub-relay.ts +38 -6
- package/src/client/lifecycle-lock.ts +129 -0
- package/src/client/state.ts +71 -42
- package/src/clients/aside-profiles.ts +224 -0
- package/src/codex/account-lifecycle.ts +5 -9
- package/src/codex/auth-api.ts +50 -4
- package/src/codex/catalog/metadata.ts +0 -2
- package/src/codex/catalog/provider-fetch.ts +7 -5
- package/src/codex/catalog/sync.ts +88 -24
- package/src/codex/convergence.ts +2 -0
- package/src/codex/inject.ts +45 -14
- package/src/codex/journal.ts +6 -6
- package/src/codex/quota-refresh-outcome.ts +27 -0
- package/src/codex/shim.ts +7 -1
- package/src/combos/resolve.ts +10 -7
- package/src/config.ts +18 -5
- package/src/generated/compatibility-version.json +209 -105
- package/src/images/loop.ts +5 -2
- package/src/integrations/aside-profile-context.ts +270 -0
- package/src/integrations/aside-profile-journal.ts +229 -0
- package/src/integrations/aside-profiles.ts +176 -0
- package/src/integrations/catalog-refresh.ts +40 -0
- package/src/integrations/config-io.ts +16 -1
- package/src/integrations/owned-refresh.ts +14 -2
- package/src/integrations/state.ts +3 -1
- package/src/integrations/writer.ts +3 -1
- package/src/lab/fabric/producer-isolate.ts +84 -46
- package/src/lib/account-selection-events.ts +32 -0
- package/src/lib/errors.ts +9 -1
- package/src/lib/provider-outbound.ts +2 -45
- package/src/lib/proxy-env.ts +67 -0
- package/src/oauth/account-quota-rank.ts +9 -1
- package/src/oauth/anthropic-routing.ts +91 -23
- package/src/oauth/generic-account-failover.ts +15 -53
- package/src/oauth/index.ts +67 -68
- package/src/oauth/pool-settings-capability.ts +2 -1
- package/src/oauth/store.ts +91 -7
- package/src/oauth/types.ts +8 -0
- package/src/providers/api-key-selection.ts +110 -0
- package/src/providers/api-keys.ts +38 -31
- package/src/providers/context-cap.ts +27 -3
- package/src/providers/key-failover.ts +27 -17
- package/src/providers/provider-id-rewrite.ts +10 -8
- package/src/providers/registry.ts +1 -0
- package/src/responses/custom-tool-compat.ts +10 -0
- package/src/responses/function-call-compat.ts +173 -0
- package/src/responses/namespace-tool-compat.ts +27 -4
- package/src/responses/parser.ts +8 -0
- package/src/responses/task-input.ts +36 -0
- package/src/responses/tool-name-aliases.ts +79 -0
- package/src/router.ts +2 -0
- package/src/routing/capability.ts +10 -3
- package/src/routing/compatibility/assemble.ts +16 -1
- package/src/routing/evaluator.ts +5 -1
- package/src/server/auth-cors.ts +3 -0
- package/src/server/chat-native.ts +44 -9
- package/src/server/claude-messages.ts +71 -36
- package/src/server/grok-responses-snapshot-repair.ts +333 -0
- package/src/server/index.ts +26 -10
- package/src/server/management/account-selection-stream.ts +70 -0
- package/src/server/management/aside-profile-routes.ts +231 -0
- package/src/server/management/config-routes.ts +36 -41
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +61 -3
- package/src/server/management/logs-usage-routes.ts +1 -0
- package/src/server/management/model-routes.ts +20 -9
- package/src/server/management/model-rows.ts +10 -2
- package/src/server/management/oauth-account-routes.ts +7 -0
- package/src/server/management/provider-routes.ts +10 -8
- package/src/server/management/route-registry.ts +20 -2
- package/src/server/management-auth.ts +16 -0
- package/src/server/relay-eager.ts +17 -5
- package/src/server/relay.ts +43 -4
- package/src/server/request-log.ts +29 -1
- package/src/server/responses/agent-task-recovery-cache.ts +11 -0
- package/src/server/responses/agent-task-recovery.ts +24 -4
- package/src/server/responses/codex-ws-pool.ts +4 -4
- package/src/server/responses/codex-ws-session.ts +2 -2
- package/src/server/responses/collaboration.ts +2 -1
- package/src/server/responses/combo-stream-preflight.ts +26 -7
- package/src/server/responses/core.ts +668 -151
- package/src/server/responses/fetch-helpers.ts +6 -1
- package/src/server/responses/ws-upstream.ts +8 -3
- package/src/server/responses-custom-tool-repair.ts +29 -9
- package/src/server/responses-function-tool-repair.ts +183 -0
- package/src/server/responses-snapshot-codec.ts +14 -0
- package/src/server/responses-snapshot-repair.ts +1 -13
- package/src/server/responses-undeclared-tool-guard.ts +1 -68
- package/src/server/sse-payload-rewrite.ts +3 -1
- package/src/service.ts +6 -2
- package/src/types/config.ts +23 -16
- package/src/types/provider.ts +14 -3
- package/src/types/request.ts +2 -0
- package/src/usage/log.ts +9 -0
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/index-DS1NE4Jn.css +0 -1
- package/gui/dist/assets/index-Djowl68T.js +0 -112
package/src/lib/proxy-env.ts
CHANGED
|
@@ -3,6 +3,73 @@ export const PROXY_ENV_KEYS = [...OUTBOUND_PROXY_ENV_KEYS, "NO_PROXY"] as const;
|
|
|
3
3
|
|
|
4
4
|
export type ProxyEnvKey = typeof PROXY_ENV_KEYS[number];
|
|
5
5
|
export type ProxyEnvMap = Record<string, string | undefined>;
|
|
6
|
+
export type ProxyRoute =
|
|
7
|
+
| { kind: "direct" }
|
|
8
|
+
| { kind: "proxy"; proxy: string }
|
|
9
|
+
| { kind: "fallback" };
|
|
10
|
+
|
|
11
|
+
export function normalizeProxyHostname(hostname: string): string {
|
|
12
|
+
const normalized = hostname.trim().toLowerCase().replace(/\.+$/, "");
|
|
13
|
+
return normalized.startsWith("[") && normalized.endsWith("]")
|
|
14
|
+
? normalized.slice(1, -1)
|
|
15
|
+
: normalized;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function noProxyMatches(
|
|
19
|
+
url: URL,
|
|
20
|
+
env: ProxyEnvMap = process.env,
|
|
21
|
+
): boolean {
|
|
22
|
+
const raw = env.NO_PROXY ?? env.no_proxy ?? "";
|
|
23
|
+
const hostname = normalizeProxyHostname(url.hostname);
|
|
24
|
+
const port = url.port || (url.protocol === "https:" || url.protocol === "wss:" ? "443" : "80");
|
|
25
|
+
for (const rawEntry of raw.split(",")) {
|
|
26
|
+
let entry = rawEntry.trim().toLowerCase();
|
|
27
|
+
if (!entry) continue;
|
|
28
|
+
if (entry === "*") return true;
|
|
29
|
+
entry = entry.replace(/^(?:https?|wss?):\/\//, "").split("/", 1)[0]!;
|
|
30
|
+
|
|
31
|
+
let entryHost = entry;
|
|
32
|
+
let entryPort = "";
|
|
33
|
+
const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry);
|
|
34
|
+
if (bracketed) {
|
|
35
|
+
entryHost = bracketed[1]!;
|
|
36
|
+
entryPort = bracketed[2] ?? "";
|
|
37
|
+
} else if ((entry.match(/:/g)?.length ?? 0) === 1) {
|
|
38
|
+
const separator = entry.lastIndexOf(":");
|
|
39
|
+
const possiblePort = entry.slice(separator + 1);
|
|
40
|
+
if (/^\d+$/.test(possiblePort)) {
|
|
41
|
+
entryHost = entry.slice(0, separator);
|
|
42
|
+
entryPort = possiblePort;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (entryPort && entryPort !== port) continue;
|
|
46
|
+
entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, ""));
|
|
47
|
+
if (entryHost && (hostname === entryHost || hostname.endsWith(`.${entryHost}`))) return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveProxyRoute(
|
|
53
|
+
url: URL,
|
|
54
|
+
env: ProxyEnvMap = process.env,
|
|
55
|
+
): ProxyRoute {
|
|
56
|
+
if (noProxyMatches(url, env)) return { kind: "direct" };
|
|
57
|
+
const key = url.protocol === "https:" || url.protocol === "wss:"
|
|
58
|
+
? "HTTPS_PROXY"
|
|
59
|
+
: "HTTP_PROXY";
|
|
60
|
+
const proxy = [key, key.toLowerCase(), "ALL_PROXY", "all_proxy"]
|
|
61
|
+
.map(candidate => env[candidate]?.trim())
|
|
62
|
+
.find(Boolean);
|
|
63
|
+
if (!proxy) return { kind: "direct" };
|
|
64
|
+
try {
|
|
65
|
+
const protocol = new URL(proxy).protocol;
|
|
66
|
+
return protocol === "http:" || protocol === "https:"
|
|
67
|
+
? { kind: "proxy", proxy }
|
|
68
|
+
: { kind: "fallback" };
|
|
69
|
+
} catch {
|
|
70
|
+
return { kind: "fallback" };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
6
73
|
|
|
7
74
|
export function proxyEnvPresent(
|
|
8
75
|
key: ProxyEnvKey,
|
|
@@ -67,6 +67,14 @@ function headroomOf(provider: string, accountId: string): number | null {
|
|
|
67
67
|
return 100 - Math.max(...percents);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Unknown usage is not exhaustion; Kiro's explicit overage verdict is authoritative. */
|
|
71
|
+
export function isAccountQuotaExhausted(provider: string, accountId: string): boolean {
|
|
72
|
+
const exhaustion = provider === "kiro" ? getKiroAccountExhaustion(`${provider}\u0000${accountId}`) : null;
|
|
73
|
+
if (exhaustion !== null) return exhaustion.exhausted;
|
|
74
|
+
const headroom = headroomOf(provider, accountId);
|
|
75
|
+
return headroom !== null && headroom <= 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
70
78
|
/**
|
|
71
79
|
* Order candidates best-first.
|
|
72
80
|
*
|
|
@@ -90,7 +98,7 @@ export function rankAccountsByHeadroom(provider: string, ring: readonly string[]
|
|
|
90
98
|
const headroom = headroomOf(provider, id);
|
|
91
99
|
if (exhaustion !== null || headroom !== null) sawEvidence = true;
|
|
92
100
|
|
|
93
|
-
if (
|
|
101
|
+
if (isAccountQuotaExhausted(provider, id)) return { id, bucket: RANK_EXHAUSTED, headroom: 0, index };
|
|
94
102
|
if (headroom === null) return { id, bucket: RANK_UNKNOWN, headroom: 0, index };
|
|
95
103
|
return { id, bucket: RANK_HEALTHY, headroom, index };
|
|
96
104
|
});
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* store (existing OAuth path) so the account is excluded from eligibility.
|
|
16
16
|
*/
|
|
17
17
|
import { createHash } from "node:crypto";
|
|
18
|
-
import {
|
|
18
|
+
import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store";
|
|
19
|
+
import type { OAuthAccessSnapshot } from "./index";
|
|
19
20
|
import { getCachedProviderAccountQuota } from "../providers/quota";
|
|
20
21
|
import { fallbackCodexAccountLogLabel } from "../codex/account-label";
|
|
21
22
|
import {
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
notePoolRotationFailure,
|
|
25
26
|
notePoolRotationSuccess,
|
|
26
27
|
pickRoundRobinAccount,
|
|
28
|
+
peekRoundRobinAccount,
|
|
27
29
|
POOL_KEY_ANTHROPIC,
|
|
28
30
|
seedPoolRotationAccount,
|
|
29
31
|
} from "../codex/pool-rotation";
|
|
@@ -68,6 +70,10 @@ interface AffinityEntry {
|
|
|
68
70
|
|
|
69
71
|
const upstreamHealth = new Map<string, AccountHealth>();
|
|
70
72
|
const sessionAffinity = new Map<string, AffinityEntry>();
|
|
73
|
+
type OAuthAccountSelection = NonNullable<ReturnType<typeof captureOAuthAccountSelection>>;
|
|
74
|
+
// Undefined means this runtime has not admitted a selection yet; null means consumed.
|
|
75
|
+
// The startup baseline comes from the authoritative store, never a second persisted pin.
|
|
76
|
+
let manualPreference: OAuthAccountSelection | null | undefined;
|
|
71
77
|
|
|
72
78
|
function normalizeAffinityComponent(value: string | null | undefined): string {
|
|
73
79
|
const normalized = value?.trim() ?? "";
|
|
@@ -152,6 +158,7 @@ export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number {
|
|
|
152
158
|
export function clearAnthropicAccountPoolState(): void {
|
|
153
159
|
upstreamHealth.clear();
|
|
154
160
|
sessionAffinity.clear();
|
|
161
|
+
manualPreference = undefined;
|
|
155
162
|
quorumCache = null;
|
|
156
163
|
}
|
|
157
164
|
|
|
@@ -409,7 +416,7 @@ function pickAlternateAnthropicAccount(
|
|
|
409
416
|
const strategy = anthropicPoolStrategy(config);
|
|
410
417
|
const eligible = getEligibleAnthropicAccounts(now).filter(id => id !== excludeId);
|
|
411
418
|
if (strategy === "round-robin") {
|
|
412
|
-
return
|
|
419
|
+
return peekRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, stickyLimitForPool(config));
|
|
413
420
|
}
|
|
414
421
|
if (strategy === "fill-first") {
|
|
415
422
|
return pickNextFillFirstAnthropicAccount(config, excludeId, eligible);
|
|
@@ -434,6 +441,7 @@ export type AnthropicAccountSelectionReason =
|
|
|
434
441
|
| "lowest-usage"
|
|
435
442
|
| "only-eligible"
|
|
436
443
|
| "round-robin"
|
|
444
|
+
| "manual"
|
|
437
445
|
| "fill-first"
|
|
438
446
|
| "none"
|
|
439
447
|
| "all-cooled";
|
|
@@ -500,9 +508,8 @@ function pickUnboundStrategyAccount(
|
|
|
500
508
|
if (strategy === "round-robin") {
|
|
501
509
|
const eligible = getEligibleAnthropicAccounts(now);
|
|
502
510
|
const limit = stickyLimitForPool(config);
|
|
503
|
-
const picked =
|
|
511
|
+
const picked = peekRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, limit);
|
|
504
512
|
if (!picked) return null;
|
|
505
|
-
notePoolRotationSuccess(POOL_KEY_ANTHROPIC, picked, limit);
|
|
506
513
|
return { accountId: picked, reason: "round-robin" };
|
|
507
514
|
}
|
|
508
515
|
|
|
@@ -528,17 +535,39 @@ export function resolveAnthropicAccountForSession(
|
|
|
528
535
|
const set = getAccountSet(PROVIDER);
|
|
529
536
|
if (!set || set.accounts.length === 0) return { accountId: null, reason: "none" };
|
|
530
537
|
|
|
538
|
+
if (manualPreference === undefined) {
|
|
539
|
+
manualPreference = set.selectionRevision !== undefined
|
|
540
|
+
? { accountId: set.activeAccountId, revision: set.selectionRevision }
|
|
541
|
+
: null;
|
|
542
|
+
}
|
|
543
|
+
|
|
531
544
|
if (!isAnthropicAccountPoolEnabled(config)) {
|
|
532
545
|
return { accountId: set.activeAccountId, reason: "pool-disabled" };
|
|
533
546
|
}
|
|
534
547
|
|
|
548
|
+
// A manual choice is a one-dispatch preference, not a lower-priority quota hint.
|
|
549
|
+
// Consume it only after admission commits, so a failed token lookup cannot spend it.
|
|
550
|
+
if (manualPreference) {
|
|
551
|
+
if (manualPreference.accountId !== set.activeAccountId || manualPreference.revision !== set.selectionRevision) {
|
|
552
|
+
manualPreference = null;
|
|
553
|
+
} else {
|
|
554
|
+
const chosen = manualPreference.accountId;
|
|
555
|
+
const quota = getCachedProviderAccountQuota(PROVIDER, chosen);
|
|
556
|
+
const exhausted = [quota?.fiveHourPercent, quota?.weeklyPercent, quota?.monthlyPercent,
|
|
557
|
+
...(quota?.customWindows ?? []).map(window => window.percent)]
|
|
558
|
+
.some(percent => typeof percent === "number" && percent >= 100);
|
|
559
|
+
if (!exhausted && getEligibleAnthropicAccounts(now).includes(chosen)) {
|
|
560
|
+
return { accountId: chosen, reason: "manual" };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
535
565
|
const key = normalizeAffinityComponent(sessionKey);
|
|
536
566
|
if (key) {
|
|
537
567
|
const affined = sessionAffinity.get(key);
|
|
538
568
|
if (affined && now - affined.lastUsedAt <= AFFINITY_IDLE_TTL_MS) {
|
|
539
569
|
const stillThere = set.accounts.some(a => a.id === affined.accountId && a.needsReauth !== true);
|
|
540
570
|
if (stillThere && !isCooled(affined.accountId, now) && isPoolCredentialUsable(affined.accountId, now)) {
|
|
541
|
-
affined.lastUsedAt = now;
|
|
542
571
|
return { accountId: affined.accountId, reason: "affinity" };
|
|
543
572
|
}
|
|
544
573
|
sessionAffinity.delete(key);
|
|
@@ -560,12 +589,6 @@ export function resolveAnthropicAccountForSession(
|
|
|
560
589
|
|
|
561
590
|
const strategyPick = pickUnboundStrategyAccount(config, now);
|
|
562
591
|
if (strategyPick) {
|
|
563
|
-
// Do not promote active here — token validation may still fail. Callers
|
|
564
|
-
// (responses/core) promote after getAnthropicPoolAccessToken succeeds.
|
|
565
|
-
if (key && normalizeAffinityComponent(strategyPick.accountId)) {
|
|
566
|
-
sessionAffinity.set(key, { accountId: strategyPick.accountId, lastUsedAt: now });
|
|
567
|
-
pruneExpiredAffinity(now);
|
|
568
|
-
}
|
|
569
592
|
return { accountId: strategyPick.accountId, reason: strategyPick.reason };
|
|
570
593
|
}
|
|
571
594
|
|
|
@@ -611,10 +634,6 @@ export function resolveAnthropicAccountForSession(
|
|
|
611
634
|
return { accountId: null, reason: anyCooled ? "all-cooled" : "none" };
|
|
612
635
|
}
|
|
613
636
|
|
|
614
|
-
if (key && normalizeAffinityComponent(accountId)) {
|
|
615
|
-
sessionAffinity.set(key, { accountId, lastUsedAt: now });
|
|
616
|
-
pruneExpiredAffinity(now);
|
|
617
|
-
}
|
|
618
637
|
return { accountId, reason };
|
|
619
638
|
}
|
|
620
639
|
|
|
@@ -684,20 +703,57 @@ export function rotateAnthropicAccountOn429(
|
|
|
684
703
|
return null;
|
|
685
704
|
}
|
|
686
705
|
|
|
687
|
-
const affinityKey = normalizeAffinityComponent(sessionKey);
|
|
688
|
-
if (affinityKey && normalizeAffinityComponent(next)) {
|
|
689
|
-
sessionAffinity.set(affinityKey, { accountId: next, lastUsedAt: now });
|
|
690
|
-
pruneExpiredAffinity(now);
|
|
691
|
-
}
|
|
692
706
|
console.warn(
|
|
693
707
|
`[anthropic-pool] 429 on ${formatAnthropicAccountOrdinal(failedAccountId)}; failing over to ${formatAnthropicAccountOrdinal(next)}`,
|
|
694
708
|
);
|
|
695
709
|
return next;
|
|
696
710
|
}
|
|
697
711
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
712
|
+
export interface AnthropicSelectionRoutingOptions {
|
|
713
|
+
config: OcxConfig;
|
|
714
|
+
sessionKey?: string | null;
|
|
715
|
+
reason?: AnthropicAccountSelectionReason;
|
|
716
|
+
expectedCredentialGeneration?: string;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Commit the selected account before dispatch; rejected proposals have no routing side effects. */
|
|
720
|
+
export async function promoteAnthropicActiveAccount(
|
|
721
|
+
accountId: string,
|
|
722
|
+
expectedSelection: OAuthAccountSelection | null,
|
|
723
|
+
options: AnthropicSelectionRoutingOptions,
|
|
724
|
+
): Promise<OAuthAccountSelection | null> {
|
|
725
|
+
if (!expectedSelection || !isPoolCredentialUsable(accountId, Date.now()) || isCooled(accountId, Date.now())) return null;
|
|
726
|
+
const committed = await commitOAuthAccountSelection(PROVIDER, accountId, {
|
|
727
|
+
expectedSelection,
|
|
728
|
+
expectedCredentialGeneration: options.expectedCredentialGeneration,
|
|
729
|
+
requireUsableAccount: true,
|
|
730
|
+
});
|
|
731
|
+
if (!committed) return null;
|
|
732
|
+
return commitAnthropicSelectionRouting(accountId, expectedSelection, committed, options) ? committed : null;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Main's shared selection owner calls this only after its authoritative commit succeeds. */
|
|
736
|
+
export function commitAnthropicSelectionRouting(
|
|
737
|
+
accountId: string,
|
|
738
|
+
expectedSelection: OAuthAccountSelection,
|
|
739
|
+
committed: OAuthAccountSelection,
|
|
740
|
+
options: AnthropicSelectionRoutingOptions,
|
|
741
|
+
): boolean {
|
|
742
|
+
if (committed.accountId !== accountId) return false;
|
|
743
|
+
const current = captureOAuthAccountSelection(PROVIDER);
|
|
744
|
+
if (current?.accountId !== committed.accountId || current.revision !== committed.revision) return false;
|
|
745
|
+
if (isAnthropicAccountPoolEnabled(options.config)) {
|
|
746
|
+
if (anthropicPoolStrategy(options.config) === "round-robin" && options.reason !== "affinity") {
|
|
747
|
+
const limit = stickyLimitForPool(options.config);
|
|
748
|
+
const picked = pickRoundRobinAccount(POOL_KEY_ANTHROPIC, getEligibleAnthropicAccounts(), limit);
|
|
749
|
+
if (picked !== accountId) seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId);
|
|
750
|
+
notePoolRotationSuccess(POOL_KEY_ANTHROPIC, accountId, limit);
|
|
751
|
+
}
|
|
752
|
+
bindAnthropicSessionAffinity(options.sessionKey, accountId);
|
|
753
|
+
}
|
|
754
|
+
if (manualPreference === undefined || (manualPreference?.accountId === expectedSelection.accountId
|
|
755
|
+
&& manualPreference.revision === expectedSelection.revision)) manualPreference = null;
|
|
756
|
+
return true;
|
|
701
757
|
}
|
|
702
758
|
|
|
703
759
|
/**
|
|
@@ -706,6 +762,7 @@ export function promoteAnthropicActiveAccount(accountId: string): void {
|
|
|
706
762
|
*/
|
|
707
763
|
export function resetAnthropicRoutingForManualSelection(accountId: string): void {
|
|
708
764
|
sessionAffinity.clear();
|
|
765
|
+
manualPreference = captureOAuthAccountSelection(PROVIDER);
|
|
709
766
|
seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId);
|
|
710
767
|
// A manual account selection is an operator statement about the roster; do not answer the
|
|
711
768
|
// next activation question from a count read before it.
|
|
@@ -731,6 +788,17 @@ export async function getAnthropicPoolAccessToken(accountId: string): Promise<st
|
|
|
731
788
|
return getValidAccessTokenForAccount(PROVIDER, accountId);
|
|
732
789
|
}
|
|
733
790
|
|
|
791
|
+
/** Resolve through the pool's local-CLI restrictions, then bind the exact returned generation. */
|
|
792
|
+
export async function getAnthropicPoolAccessSnapshot(accountId: string): Promise<OAuthAccessSnapshot> {
|
|
793
|
+
const accessToken = await getAnthropicPoolAccessToken(accountId);
|
|
794
|
+
const row = getAccountCredentialWithStatus(PROVIDER, accountId);
|
|
795
|
+
if (!row || row.needsReauth || row.credential.access !== accessToken
|
|
796
|
+
|| row.credential.expires <= Date.now()) {
|
|
797
|
+
throw new Error("Anthropic pool credential changed during account selection");
|
|
798
|
+
}
|
|
799
|
+
return { provider: PROVIDER, accountId, accessToken, generation: credentialGeneration(row.credential) };
|
|
800
|
+
}
|
|
801
|
+
|
|
734
802
|
/**
|
|
735
803
|
* Whether the pool may refresh this account's token. Background `local-cli` slots must not
|
|
736
804
|
* adopt the global Claude CLI credential (same fail-closed rule as quota probes).
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { getAccountSet } from "./store";
|
|
18
18
|
import { getValidAccessSnapshotForAccount, type OAuthAccessSnapshot } from "./index";
|
|
19
|
-
import { exhaustedCooldownMs, hasHeadroomEvidence, rankAccountsByHeadroom } from "./account-quota-rank";
|
|
19
|
+
import { exhaustedCooldownMs, hasHeadroomEvidence, isAccountQuotaExhausted, rankAccountsByHeadroom } from "./account-quota-rank";
|
|
20
20
|
import { parseRetryAfterMs } from "../combos/failover";
|
|
21
21
|
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
22
22
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
@@ -61,29 +61,12 @@ interface PresenceEntry {
|
|
|
61
61
|
readAt: number;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
/**
|
|
65
|
-
* Ordered roster plus the active id, for the pre-dispatch preference.
|
|
66
|
-
*
|
|
67
|
-
* Same reasoning as the presence cache: `getAccountSet` reads through `loadAuthStore`,
|
|
68
|
-
* which chmods and re-parses the whole credential file on every call. Selection needs the
|
|
69
|
-
* ORDER and the active id, which the presence count cannot supply, so it gets its own
|
|
70
|
-
* TTL-bounded row. Ids and an active pointer only — never a credential.
|
|
71
|
-
*/
|
|
72
|
-
interface RosterEntry {
|
|
73
|
-
ids: string[];
|
|
74
|
-
activeId: string | null;
|
|
75
|
-
readAt: number;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
64
|
/** Process-local, like the Anthropic pool's: a restart is allowed to forget a cooldown. */
|
|
79
65
|
const health = new Map<string, AccountHealth>();
|
|
80
66
|
|
|
81
67
|
/** Provider -> recent eligible-account count. TTL-bounded; never holds credential material. */
|
|
82
68
|
const presence = new Map<string, PresenceEntry>();
|
|
83
69
|
|
|
84
|
-
/** Provider -> recently read roster. TTL-bounded; never holds credential material. */
|
|
85
|
-
const roster = new Map<string, RosterEntry>();
|
|
86
|
-
|
|
87
70
|
const healthKey = (provider: string, accountId: string) => `${provider}\u0000${accountId}`;
|
|
88
71
|
|
|
89
72
|
function isCooled(provider: string, accountId: string, now: number): boolean {
|
|
@@ -118,24 +101,6 @@ function eligibleAccountCount(providerName: string, now: number): number {
|
|
|
118
101
|
return eligible;
|
|
119
102
|
}
|
|
120
103
|
|
|
121
|
-
/**
|
|
122
|
-
* Roster ids and the active pointer, read at most once per TTL window.
|
|
123
|
-
*
|
|
124
|
-
* `needsReauth` accounts are excluded for the same reason the presence count excludes
|
|
125
|
-
* them: a revoked credential cannot serve the request we are about to send.
|
|
126
|
-
*/
|
|
127
|
-
function cachedRoster(providerName: string, now: number): { ids: string[]; activeId: string | null } {
|
|
128
|
-
const cached = roster.get(providerName);
|
|
129
|
-
if (cached && now >= cached.readAt && now - cached.readAt < PRESENCE_CACHE_TTL_MS) {
|
|
130
|
-
return { ids: cached.ids, activeId: cached.activeId };
|
|
131
|
-
}
|
|
132
|
-
const set = getAccountSet(providerName);
|
|
133
|
-
const ids = set ? set.accounts.filter(a => a.needsReauth !== true).map(a => a.id) : [];
|
|
134
|
-
const activeId = set?.activeAccountId ?? null;
|
|
135
|
-
roster.set(providerName, { ids, activeId, readAt: now });
|
|
136
|
-
return { ids, activeId };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
104
|
/**
|
|
140
105
|
* Presence IS consent (#2568d).
|
|
141
106
|
*
|
|
@@ -192,8 +157,7 @@ function isProactivePreferenceEnabled(config: OcxConfig, providerName: string, n
|
|
|
192
157
|
if (typeof perProvider === "boolean") {
|
|
193
158
|
return perProvider && hasFailoverAccountQuorum(providerName, now);
|
|
194
159
|
}
|
|
195
|
-
|
|
196
|
-
return hasFailoverAccountQuorum(providerName, now);
|
|
160
|
+
return config.oauthAccountFailover?.enabled === true && hasFailoverAccountQuorum(providerName, now);
|
|
197
161
|
}
|
|
198
162
|
|
|
199
163
|
/** Accounts that may serve traffic right now: not cooled, not flagged for reauth. */
|
|
@@ -240,8 +204,6 @@ export function rotateGenericOAuthAccountOn429(
|
|
|
240
204
|
// A rotation means the roster in use just changed; do not answer the next activation question
|
|
241
205
|
// from a count read before the failure.
|
|
242
206
|
presence.delete(providerName);
|
|
243
|
-
// Same for the selection roster: the next request must not pick from a pre-failure read.
|
|
244
|
-
roster.delete(providerName);
|
|
245
207
|
// Deterministic: start after the failed account so repeated 429s walk the roster instead of
|
|
246
208
|
// hammering whichever id happens to sort first. The ring is built BEFORE ranking — ranking
|
|
247
209
|
// the store's own order would change which account a quota-less provider rotates to.
|
|
@@ -289,13 +251,19 @@ export function preferredInitialAccount(
|
|
|
289
251
|
// The PROACTIVE predicate, not the reactive one: this steers a request upstream has not
|
|
290
252
|
// refused, so `oauthAccountFailover.enabled: false` must still be able to refuse it.
|
|
291
253
|
if (!isProactivePreferenceEnabled(config, providerName, now)) return null;
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const
|
|
254
|
+
// Read the same authoritative selection the management writer commits. Caching the
|
|
255
|
+
// active id separately would delay manual selection and account removal.
|
|
256
|
+
const selected = getAccountSet(providerName);
|
|
257
|
+
if (!selected) return null;
|
|
258
|
+
const active = selected.activeAccountId;
|
|
259
|
+
const order = selected.accounts.filter(account => account.needsReauth !== true).map(account => account.id);
|
|
297
260
|
if (order.length < 2) return null;
|
|
298
261
|
|
|
262
|
+
const activeRow = selected.accounts.find(account => account.id === active);
|
|
263
|
+
if (activeRow && activeRow.needsReauth !== true
|
|
264
|
+
&& !isCooled(providerName, activeRow.id, now)
|
|
265
|
+
&& !isAccountQuotaExhausted(providerName, activeRow.id)) return null;
|
|
266
|
+
|
|
299
267
|
// Evidence is required BEFORE eligibility narrows the field. Without this, a provider
|
|
300
268
|
// with no quota data at all could still be redirected: cool the active account with a
|
|
301
269
|
// 429 and the eligible list collapses to one candidate, which any ranking returns
|
|
@@ -318,11 +286,8 @@ export function preferredInitialAccount(
|
|
|
318
286
|
const best = rankAccountsByHeadroom(providerName, candidates)[0] ?? null;
|
|
319
287
|
// Nothing to do when the ranking agrees with the account we would have used anyway.
|
|
320
288
|
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
// which rejects a removed or reauth-flagged account inside the store read it was already
|
|
324
|
-
// performing, and falls back to the active account. Validating here instead would mean a
|
|
325
|
-
// second uncached read of the credential file on every redirected request.
|
|
289
|
+
// A proposal still needs guarded selection commit after credential resolution: a
|
|
290
|
+
// removal, reauth verdict, or manual choice can arrive during that await.
|
|
326
291
|
return best && best !== active ? best : null;
|
|
327
292
|
}
|
|
328
293
|
|
|
@@ -341,7 +306,6 @@ export function genericFailoverRetryAfterSeconds(providerName: string, now = Dat
|
|
|
341
306
|
|
|
342
307
|
/** Test seam and manual-recovery hook. */
|
|
343
308
|
export function forgetGenericFailoverRoster(providerName: string): void {
|
|
344
|
-
roster.delete(providerName);
|
|
345
309
|
presence.delete(providerName);
|
|
346
310
|
}
|
|
347
311
|
|
|
@@ -350,11 +314,9 @@ export function clearGenericFailoverHealth(providerName?: string): void {
|
|
|
350
314
|
if (!providerName) {
|
|
351
315
|
health.clear();
|
|
352
316
|
presence.clear();
|
|
353
|
-
roster.clear();
|
|
354
317
|
return;
|
|
355
318
|
}
|
|
356
319
|
presence.delete(providerName);
|
|
357
|
-
roster.delete(providerName);
|
|
358
320
|
for (const key of [...health.keys()]) {
|
|
359
321
|
if (key.startsWith(`${providerName}\u0000`)) health.delete(key);
|
|
360
322
|
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -548,7 +548,7 @@ export async function getValidAccessTokenSnapshot(provider: string): Promise<OAu
|
|
|
548
548
|
}
|
|
549
549
|
|
|
550
550
|
/** Providers whose upstream-401 replay path may force a snapshot refresh. */
|
|
551
|
-
const FORCE_REFRESH_PROVIDERS = new Set(["xai", "github-copilot", "kiro"]);
|
|
551
|
+
const FORCE_REFRESH_PROVIDERS = new Set(["xai", "github-copilot", "kiro", "google-antigravity"]);
|
|
552
552
|
|
|
553
553
|
export async function forceRefreshOAuthAccessSnapshot(
|
|
554
554
|
rejected: OAuthAccessSnapshot,
|
|
@@ -1239,6 +1239,37 @@ function isLegacyAntigravityStaticCatalog(provider: OcxProviderConfig): boolean
|
|
|
1239
1239
|
]);
|
|
1240
1240
|
}
|
|
1241
1241
|
|
|
1242
|
+
/** Refresh registry-owned catalog fields while preserving valid operator selections. */
|
|
1243
|
+
function applyOAuthPresetCatalog(
|
|
1244
|
+
provider: OcxProviderConfig,
|
|
1245
|
+
preset: OcxProviderConfig,
|
|
1246
|
+
): void {
|
|
1247
|
+
for (const field of OAUTH_RECONCILE_FIELDS) {
|
|
1248
|
+
if (JSON.stringify(provider[field]) === JSON.stringify(preset[field])) continue;
|
|
1249
|
+
if (preset[field] !== undefined) {
|
|
1250
|
+
provider[field] = cloneProviderField(preset[field]) as never;
|
|
1251
|
+
} else {
|
|
1252
|
+
delete provider[field];
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
if (provider.liveModels === undefined && preset.liveModels !== undefined) {
|
|
1256
|
+
provider.liveModels = preset.liveModels;
|
|
1257
|
+
}
|
|
1258
|
+
// Heal only a selection that the refreshed static catalog no longer contains. Providers
|
|
1259
|
+
// with live discovery do not expose an enumerable account catalog here, so their saved
|
|
1260
|
+
// default remains operator-owned.
|
|
1261
|
+
if (
|
|
1262
|
+
provider.liveModels !== true &&
|
|
1263
|
+
provider.defaultModel
|
|
1264
|
+
&& preset.defaultModel
|
|
1265
|
+
&& preset.models
|
|
1266
|
+
&& preset.models.length > 0
|
|
1267
|
+
&& !(provider.models ?? []).includes(provider.defaultModel)
|
|
1268
|
+
) {
|
|
1269
|
+
provider.defaultModel = preset.defaultModel;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1242
1273
|
/** Promote only the versioned canonical static seed; unmarked `liveModels: false` remains user intent. */
|
|
1243
1274
|
function migrateLegacyAntigravityStaticCatalog(config: OcxConfig): boolean {
|
|
1244
1275
|
if (config.googleAntigravityStaticCatalogVersion !== 1) return false;
|
|
@@ -1277,24 +1308,7 @@ function projectOAuthProviderReconciliation(config: OcxConfig): OAuthReconcilePr
|
|
|
1277
1308
|
}
|
|
1278
1309
|
if (def && prov.authMode === "oauth") {
|
|
1279
1310
|
const preset = def.providerConfig;
|
|
1280
|
-
|
|
1281
|
-
if (JSON.stringify(prov[field]) === JSON.stringify(preset[field])) continue;
|
|
1282
|
-
if (preset[field] !== undefined) {
|
|
1283
|
-
prov[field] = cloneProviderField(preset[field]) as never;
|
|
1284
|
-
} else {
|
|
1285
|
-
delete prov[field];
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
if (prov.liveModels === undefined && preset.liveModels !== undefined) {
|
|
1289
|
-
prov.liveModels = preset.liveModels;
|
|
1290
|
-
}
|
|
1291
|
-
// Heal a defaultModel that no longer exists in the refreshed list (e.g. a deprecated snapshot).
|
|
1292
|
-
// Skip providers without a static preset `models` list: for live-discovery providers
|
|
1293
|
-
// (e.g. command-code OAuth) the account-scoped catalog is not enumerable here, so any
|
|
1294
|
-
// persisted defaultModel is a user selection and must not be overwritten by the seed.
|
|
1295
|
-
if (prov.defaultModel && preset.defaultModel && preset.models && preset.models.length > 0 && !(prov.models ?? []).includes(prov.defaultModel)) {
|
|
1296
|
-
prov.defaultModel = preset.defaultModel;
|
|
1297
|
-
}
|
|
1311
|
+
applyOAuthPresetCatalog(prov, preset);
|
|
1298
1312
|
}
|
|
1299
1313
|
if (JSON.stringify(prov) !== beforeProvider) {
|
|
1300
1314
|
changed = true;
|
|
@@ -1408,24 +1422,18 @@ function preservableApiKeyPool(value: unknown): NonNullable<OcxProviderConfig["a
|
|
|
1408
1422
|
return pool.length > 0 ? pool : undefined;
|
|
1409
1423
|
}
|
|
1410
1424
|
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
* After preservation, `apiKey` always has exactly one matching pool entry (inserting via the
|
|
1424
|
-
* same content-derived id as the API-key manager when the active key was missing from the
|
|
1425
|
-
* pool). Key mode reflects stored user intent (explicit `"key"` or omitted mode with safe
|
|
1426
|
-
* key material) — never whether the login CLI process can resolve an env reference. Env-backed
|
|
1427
|
-
* availability is decided at proxy routing time in `router.ts`.
|
|
1428
|
-
*/
|
|
1425
|
+
const OAUTH_LOGIN_OWNED_PROVIDER_FIELDS = [
|
|
1426
|
+
"adapter",
|
|
1427
|
+
"baseUrl",
|
|
1428
|
+
"authMode",
|
|
1429
|
+
"headers",
|
|
1430
|
+
"apiKeyTransport",
|
|
1431
|
+
"responsesPath",
|
|
1432
|
+
"googleMode",
|
|
1433
|
+
"keyOptional",
|
|
1434
|
+
] as const satisfies readonly (keyof OcxProviderConfig)[];
|
|
1435
|
+
|
|
1436
|
+
/** Add/refresh only an OAuth provider's login-owned config fields (does not persist). */
|
|
1429
1437
|
export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
|
|
1430
1438
|
if (provider === "chatgpt") return;
|
|
1431
1439
|
const def = OAUTH_PROVIDERS[provider];
|
|
@@ -1434,40 +1442,31 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
|
|
|
1434
1442
|
const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider);
|
|
1435
1443
|
if (namespaceCollision) throw new Error(namespaceCollision);
|
|
1436
1444
|
const existing = config.providers[provider];
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
// The Command Code protocol-version pin is an operator compatibility control. A re-login,
|
|
1444
|
-
// add-account, or reauth rebuilds the row from the preset, which has no version; carry the
|
|
1445
|
-
// existing pin so authentication changes do not silently revert the documented control.
|
|
1446
|
-
if (existing?.commandCodeVersion !== undefined) {
|
|
1447
|
-
next.commandCodeVersion = existing.commandCodeVersion;
|
|
1448
|
-
}
|
|
1449
|
-
// Reauth/add-account refreshes credentials, not the operator's post-upgrade wire choice.
|
|
1450
|
-
if (provider === "xai") {
|
|
1451
|
-
if (existing?.modelAdapters !== undefined) next.modelAdapters = { ...existing.modelAdapters };
|
|
1452
|
-
if (existing?.xaiResponsesDefaultVersion !== undefined) {
|
|
1453
|
-
next.xaiResponsesDefaultVersion = existing.xaiResponsesDefaultVersion;
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
|
-
// User-configured price overlays are operator data, not preset state; a
|
|
1457
|
-
// re-login, add-account, or reauth must not silently drop them from the
|
|
1458
|
-
// Logs/Usage estimates.
|
|
1459
|
-
if (existing?.modelCosts !== undefined) {
|
|
1460
|
-
next.modelCosts = existing.modelCosts;
|
|
1445
|
+
// Clone operator state, including xAI wire choices and their migration version.
|
|
1446
|
+
const next: OcxProviderConfig = structuredClone(existing ?? def.providerConfig);
|
|
1447
|
+
for (const field of OAUTH_LOGIN_OWNED_PROVIDER_FIELDS) {
|
|
1448
|
+
const value = def.providerConfig[field];
|
|
1449
|
+
if (value === undefined) delete next[field];
|
|
1450
|
+
else next[field] = structuredClone(value) as never;
|
|
1461
1451
|
}
|
|
1462
|
-
//
|
|
1463
|
-
//
|
|
1464
|
-
|
|
1465
|
-
//
|
|
1466
|
-
//
|
|
1467
|
-
|
|
1468
|
-
|
|
1452
|
+
// A login may activate a different account. CCA dispatch must take that account's
|
|
1453
|
+
// project from its credential snapshot, never retain the previous account's project.
|
|
1454
|
+
if (next.googleMode === "cloud-code-assist") delete next.project;
|
|
1455
|
+
// Login used to rebuild the whole row from the preset, so catalog data refreshed
|
|
1456
|
+
// immediately. Keep that timing without overwriting unrelated operator-owned fields.
|
|
1457
|
+
applyOAuthPresetCatalog(next, def.providerConfig);
|
|
1458
|
+
// The original Command Code seed was an implementation-owned static catalog, not an
|
|
1459
|
+
// operator opt-out. Promote that exact legacy shape when OAuth login refreshes the row.
|
|
1460
|
+
if (provider === "command-code" && existing && isLegacyCommandCodeStaticCatalog(existing)) {
|
|
1461
|
+
next.liveModels = def.providerConfig.liveModels;
|
|
1469
1462
|
}
|
|
1463
|
+
// OAuth-only providers must never retain credentials for a different auth mechanism.
|
|
1464
|
+
delete next.apiKey;
|
|
1465
|
+
delete next.apiKeyPool;
|
|
1466
|
+
delete (next as unknown as Record<string, unknown>).azureCredential;
|
|
1470
1467
|
if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) {
|
|
1468
|
+
// Retain stored key billing intent without resolving env references in the login process.
|
|
1469
|
+
// An explicit OAuth choice stays OAuth even when usable key material is retained.
|
|
1471
1470
|
// Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes.
|
|
1472
1471
|
let storedApiKey = sanitizeApiKeyValue(existing.apiKey);
|
|
1473
1472
|
const storedApiKeyPool = preservableApiKeyPool(existing.apiKeyPool);
|
|
@@ -10,7 +10,8 @@ import type { OcxProviderConfig } from "../types";
|
|
|
10
10
|
*
|
|
11
11
|
* `strategy` and `autoSwitchThreshold` are still a declared contract the selector does not
|
|
12
12
|
* consume — that is what `inert` reports. `enabled` is NOT inert any more: an explicit
|
|
13
|
-
* `
|
|
13
|
+
* `true` enables pre-dispatch exhaustion avoidance (`preferredInitialAccount`); absence is off.
|
|
14
|
+
* Healthy manual selections remain authoritative. What the switch can
|
|
14
15
|
* no longer do is refuse reactive 429 rotation, which activates on account presence and is not
|
|
15
16
|
* disableable.
|
|
16
17
|
*/
|