@yansigit/opencodex 2.31.1 → 2.31.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/{index-DJDp_XER.js → index-Cxt5fZMP.js} +14 -14
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/base.ts +6 -0
- package/src/adapters/command-code-project-context.ts +377 -0
- package/src/adapters/command-code.ts +5 -1
- package/src/adapters/cursor/cursor-errors.ts +12 -0
- package/src/adapters/cursor/live-transport.ts +21 -0
- package/src/adapters/cursor/native-exec-bridge.ts +141 -0
- package/src/adapters/cursor/thread-continuity.ts +71 -0
- package/src/adapters/cursor.ts +90 -28
- package/src/adapters/google-http.ts +12 -2
- package/src/adapters/google-wire-compiler.ts +91 -2
- package/src/adapters/google.ts +83 -15
- package/src/config.ts +2 -0
- package/src/generated/compatibility-version.json +57 -25
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/oauth/anthropic-routing.ts +129 -219
- package/src/oauth/antigravity-routing.ts +165 -0
- package/src/oauth/cursor-routing.ts +252 -0
- package/src/oauth/index.ts +3 -0
- package/src/providers/cursor-pool.ts +3 -3
- package/src/routing/account-pool/affinity.ts +125 -0
- package/src/routing/account-pool/cooldown.ts +145 -0
- package/src/routing/account-pool/index.ts +45 -0
- package/src/routing/account-pool/resolve.ts +356 -0
- package/src/routing/account-pool/types.ts +32 -0
- package/src/routing/compatibility/behavior.ts +3 -0
- package/src/server/management/oauth-account-routes.ts +47 -12
- package/src/server/responses/core.ts +400 -72
- package/src/types/config.ts +8 -0
- package/src/types/provider.ts +7 -0
- package/src/types/request.ts +13 -3
- package/src/usage/log.ts +4 -0
- package/src/web-search/gemini-executor.ts +35 -13
- package/src/web-search/index.ts +85 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { CooldownRegistry } from "./types";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60_000;
|
|
4
|
+
export const MAX_RATE_LIMIT_COOLDOWN_MS = 15 * 60_000;
|
|
5
|
+
export const DEFAULT_BILLING_COOLDOWN_MS = 24 * 60 * 60_000;
|
|
6
|
+
export const STICK_WAIT_MAX_MS = 5_000;
|
|
7
|
+
|
|
8
|
+
export type PoolCooldownReason = "rate_limit" | "billing";
|
|
9
|
+
|
|
10
|
+
interface CooldownEntry {
|
|
11
|
+
until: number;
|
|
12
|
+
source?: string;
|
|
13
|
+
reason?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createRegistry(): CooldownRegistry & { entries: Map<string, CooldownEntry> } {
|
|
17
|
+
const entries = new Map<string, CooldownEntry>();
|
|
18
|
+
return {
|
|
19
|
+
entries,
|
|
20
|
+
set(accountId, until, meta) {
|
|
21
|
+
entries.set(accountId, { until, source: meta?.source, reason: meta?.reason });
|
|
22
|
+
},
|
|
23
|
+
get(accountId, now = Date.now()) {
|
|
24
|
+
const entry = entries.get(accountId);
|
|
25
|
+
if (!entry) return null;
|
|
26
|
+
if (entry.until <= now) {
|
|
27
|
+
entries.delete(accountId);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return { until: entry.until, reason: entry.reason, source: entry.source };
|
|
31
|
+
},
|
|
32
|
+
clear(accountId) {
|
|
33
|
+
entries.delete(accountId);
|
|
34
|
+
},
|
|
35
|
+
sweep(now = Date.now()) {
|
|
36
|
+
let removed = 0;
|
|
37
|
+
for (const [accountId, entry] of entries) {
|
|
38
|
+
if (entry.until > now) continue;
|
|
39
|
+
entries.delete(accountId);
|
|
40
|
+
removed += 1;
|
|
41
|
+
}
|
|
42
|
+
return removed;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const registryByPool = new Map<string, CooldownRegistry>();
|
|
48
|
+
|
|
49
|
+
export function getPoolCooldownRegistry(poolKey: string): CooldownRegistry {
|
|
50
|
+
let registry = registryByPool.get(poolKey);
|
|
51
|
+
if (!registry) {
|
|
52
|
+
registry = createRegistry();
|
|
53
|
+
registryByPool.set(poolKey, registry);
|
|
54
|
+
}
|
|
55
|
+
return registry;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function clearCooldownState(poolKey?: string): void {
|
|
59
|
+
if (poolKey === undefined) {
|
|
60
|
+
registryByPool.clear();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
registryByPool.delete(poolKey);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseRetryAfterMs(
|
|
67
|
+
value: string | null | undefined,
|
|
68
|
+
now = Date.now(),
|
|
69
|
+
): number | undefined {
|
|
70
|
+
const text = value?.trim();
|
|
71
|
+
if (!text) return undefined;
|
|
72
|
+
if (/^\d+(?:\.\d+)?$/.test(text)) {
|
|
73
|
+
const seconds = Number(text);
|
|
74
|
+
if (Number.isFinite(seconds) && seconds > 0) {
|
|
75
|
+
return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_RATE_LIMIT_COOLDOWN_MS);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const timestamp = Date.parse(text);
|
|
79
|
+
if (!Number.isFinite(timestamp)) return undefined;
|
|
80
|
+
const delay = timestamp - now;
|
|
81
|
+
return delay > 0 ? Math.min(delay, MAX_RATE_LIMIT_COOLDOWN_MS) : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Classify HTTP status for account-pool cooldown (billing never enters the 429 hop). */
|
|
85
|
+
export function classifyPoolHttpStatus(status: number): PoolCooldownReason | null {
|
|
86
|
+
if (status === 429) return "rate_limit";
|
|
87
|
+
if (status === 402) return "billing";
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function cooldownDurationMs(reason: PoolCooldownReason, retryAfterMs: number | undefined): number {
|
|
92
|
+
if (reason === "billing") {
|
|
93
|
+
return retryAfterMs ?? DEFAULT_BILLING_COOLDOWN_MS;
|
|
94
|
+
}
|
|
95
|
+
return retryAfterMs ?? DEFAULT_RATE_LIMIT_COOLDOWN_MS;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function recordPoolAccountCooldown(
|
|
99
|
+
poolKey: string,
|
|
100
|
+
accountId: string,
|
|
101
|
+
reason: PoolCooldownReason,
|
|
102
|
+
retryAfterHeader: string | null | undefined,
|
|
103
|
+
now = Date.now(),
|
|
104
|
+
): number {
|
|
105
|
+
const parsedRetry = reason === "rate_limit" ? parseRetryAfterMs(retryAfterHeader, now) : undefined;
|
|
106
|
+
const cooldownMs = cooldownDurationMs(reason, parsedRetry);
|
|
107
|
+
const registry = getPoolCooldownRegistry(poolKey);
|
|
108
|
+
registry.set(accountId, now + cooldownMs, {
|
|
109
|
+
source: parsedRetry ? "retry-after" : "default",
|
|
110
|
+
reason,
|
|
111
|
+
});
|
|
112
|
+
return cooldownMs;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function isAccountInCooldown(
|
|
116
|
+
poolKey: string,
|
|
117
|
+
accountId: string,
|
|
118
|
+
now = Date.now(),
|
|
119
|
+
): { until: number; reason?: string } | null {
|
|
120
|
+
return getPoolCooldownRegistry(poolKey).get(accountId, now);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function isRateLimitStickWait(
|
|
124
|
+
poolKey: string,
|
|
125
|
+
accountId: string,
|
|
126
|
+
now = Date.now(),
|
|
127
|
+
): boolean {
|
|
128
|
+
const entry = isAccountInCooldown(poolKey, accountId, now);
|
|
129
|
+
if (!entry || entry.reason !== "rate_limit") return false;
|
|
130
|
+
return entry.until - now <= STICK_WAIT_MAX_MS;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function isAccountPoolEligible(
|
|
134
|
+
poolKey: string,
|
|
135
|
+
accountId: string,
|
|
136
|
+
now: number,
|
|
137
|
+
options?: { allowStickWait?: boolean },
|
|
138
|
+
): boolean {
|
|
139
|
+
const entry = isAccountInCooldown(poolKey, accountId, now);
|
|
140
|
+
if (!entry) return true;
|
|
141
|
+
if (options?.allowStickWait && entry.reason === "rate_limit" && entry.until - now <= STICK_WAIT_MAX_MS) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export {
|
|
2
|
+
ACCOUNT_POOL_MAX_FAILOVERS,
|
|
3
|
+
type AccountPoolPickReason,
|
|
4
|
+
type AccountPoolPlugin,
|
|
5
|
+
type CooldownRegistry,
|
|
6
|
+
} from "./types";
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
AFFINITY_IDLE_TTL_MS,
|
|
10
|
+
MAX_AFFINITY_ENTRIES,
|
|
11
|
+
MAX_AFFINITY_COMPONENT_BYTES,
|
|
12
|
+
affinitySizeForTests,
|
|
13
|
+
bindSessionAffinity,
|
|
14
|
+
buildSessionKeyFromParts,
|
|
15
|
+
clearAffinityState,
|
|
16
|
+
clearSessionAffinityForAccount,
|
|
17
|
+
getSessionAffinity,
|
|
18
|
+
normalizeAffinityComponent,
|
|
19
|
+
touchSessionAffinity,
|
|
20
|
+
} from "./affinity";
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
DEFAULT_BILLING_COOLDOWN_MS,
|
|
24
|
+
DEFAULT_RATE_LIMIT_COOLDOWN_MS,
|
|
25
|
+
MAX_RATE_LIMIT_COOLDOWN_MS,
|
|
26
|
+
STICK_WAIT_MAX_MS,
|
|
27
|
+
classifyPoolHttpStatus,
|
|
28
|
+
clearCooldownState,
|
|
29
|
+
getPoolCooldownRegistry,
|
|
30
|
+
isAccountInCooldown,
|
|
31
|
+
isAccountPoolEligible,
|
|
32
|
+
isRateLimitStickWait,
|
|
33
|
+
parseRetryAfterMs,
|
|
34
|
+
recordPoolAccountCooldown,
|
|
35
|
+
type PoolCooldownReason,
|
|
36
|
+
} from "./cooldown";
|
|
37
|
+
|
|
38
|
+
export {
|
|
39
|
+
clearAccountPoolState,
|
|
40
|
+
clearResolveState,
|
|
41
|
+
resetPoolFailoverCount,
|
|
42
|
+
resolvePoolAccount,
|
|
43
|
+
rotatePoolAccountOn429,
|
|
44
|
+
rotatePoolAccountOnAuth,
|
|
45
|
+
} from "./resolve";
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeAccountPoolStickyLimit,
|
|
3
|
+
notePoolRotationFailure,
|
|
4
|
+
notePoolRotationSuccess,
|
|
5
|
+
pickRoundRobinAccount,
|
|
6
|
+
} from "../../codex/pool-rotation";
|
|
7
|
+
import {
|
|
8
|
+
bindSessionAffinity,
|
|
9
|
+
clearAffinityState,
|
|
10
|
+
clearSessionAffinityForAccount,
|
|
11
|
+
getSessionAffinity,
|
|
12
|
+
normalizeAffinityComponent,
|
|
13
|
+
touchSessionAffinity,
|
|
14
|
+
} from "./affinity";
|
|
15
|
+
import {
|
|
16
|
+
clearCooldownState,
|
|
17
|
+
isAccountPoolEligible,
|
|
18
|
+
isRateLimitStickWait,
|
|
19
|
+
recordPoolAccountCooldown,
|
|
20
|
+
STICK_WAIT_MAX_MS,
|
|
21
|
+
} from "./cooldown";
|
|
22
|
+
import {
|
|
23
|
+
ACCOUNT_POOL_MAX_FAILOVERS,
|
|
24
|
+
type AccountPoolPickReason,
|
|
25
|
+
type AccountPoolPlugin,
|
|
26
|
+
} from "./types";
|
|
27
|
+
|
|
28
|
+
const UNKNOWN_USAGE_SCORE = 100;
|
|
29
|
+
const DEFAULT_AUTO_SWITCH_THRESHOLD = 80;
|
|
30
|
+
|
|
31
|
+
const failoverCountByPool = new Map<string, Map<string, number>>();
|
|
32
|
+
|
|
33
|
+
function failoverKey(sessionKey: string | null): string {
|
|
34
|
+
return normalizeAffinityComponent(sessionKey) || "__unbound__";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getFailoverMap(poolKey: string): Map<string, number> {
|
|
38
|
+
let map = failoverCountByPool.get(poolKey);
|
|
39
|
+
if (!map) {
|
|
40
|
+
map = new Map();
|
|
41
|
+
failoverCountByPool.set(poolKey, map);
|
|
42
|
+
}
|
|
43
|
+
return map;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function clearResolveState(poolKey?: string): void {
|
|
47
|
+
if (poolKey === undefined) {
|
|
48
|
+
failoverCountByPool.clear();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
failoverCountByPool.delete(poolKey);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function clearAccountPoolState(poolKey?: string): void {
|
|
55
|
+
clearResolveState(poolKey);
|
|
56
|
+
clearAffinityState(poolKey);
|
|
57
|
+
clearCooldownState(poolKey);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function filterKernelEligible(
|
|
61
|
+
plugin: AccountPoolPlugin,
|
|
62
|
+
ids: readonly string[],
|
|
63
|
+
now: number,
|
|
64
|
+
stickWaitAccountId?: string,
|
|
65
|
+
): string[] {
|
|
66
|
+
return ids.filter(id =>
|
|
67
|
+
isAccountPoolEligible(plugin.poolKey, id, now, {
|
|
68
|
+
allowStickWait: stickWaitAccountId === id,
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function usageScore(plugin: AccountPoolPlugin, accountId: string): number {
|
|
74
|
+
const score = plugin.usageScore?.(accountId);
|
|
75
|
+
if (typeof score !== "number" || !Number.isFinite(score)) return UNKNOWN_USAGE_SCORE;
|
|
76
|
+
return Math.max(0, Math.min(100, score));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function pickLowestUsage(
|
|
80
|
+
plugin: AccountPoolPlugin,
|
|
81
|
+
eligible: readonly string[],
|
|
82
|
+
excludeId?: string,
|
|
83
|
+
): string | null {
|
|
84
|
+
const candidates = excludeId ? eligible.filter(id => id !== excludeId) : [...eligible];
|
|
85
|
+
if (candidates.length === 0) return null;
|
|
86
|
+
let best = candidates[0]!;
|
|
87
|
+
let bestScore = usageScore(plugin, best);
|
|
88
|
+
for (let i = 1; i < candidates.length; i++) {
|
|
89
|
+
const id = candidates[i]!;
|
|
90
|
+
const score = usageScore(plugin, id);
|
|
91
|
+
if (score < bestScore) {
|
|
92
|
+
best = id;
|
|
93
|
+
bestScore = score;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return best;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function pickFillFirst(
|
|
100
|
+
eligible: readonly string[],
|
|
101
|
+
activeAccountId: string | undefined,
|
|
102
|
+
): string | null {
|
|
103
|
+
if (eligible.length === 0) return null;
|
|
104
|
+
const ordered = [...eligible].sort((a, b) => a.localeCompare(b));
|
|
105
|
+
if (!activeAccountId || !ordered.includes(activeAccountId)) return ordered[0] ?? null;
|
|
106
|
+
const startIdx = ordered.indexOf(activeAccountId);
|
|
107
|
+
for (let step = 1; step <= ordered.length; step++) {
|
|
108
|
+
const candidate = ordered[(startIdx + step) % ordered.length]!;
|
|
109
|
+
if (eligible.includes(candidate)) return candidate;
|
|
110
|
+
}
|
|
111
|
+
return ordered[0] ?? null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function anyCooled(plugin: AccountPoolPlugin, accountIds: readonly string[], now: number): boolean {
|
|
115
|
+
return accountIds.some(id => !isAccountPoolEligible(plugin.poolKey, id, now));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function bindIfPossible(
|
|
119
|
+
plugin: AccountPoolPlugin,
|
|
120
|
+
sessionKey: string | null,
|
|
121
|
+
accountId: string | null,
|
|
122
|
+
now: number,
|
|
123
|
+
): void {
|
|
124
|
+
if (!accountId) return;
|
|
125
|
+
const key = normalizeAffinityComponent(sessionKey);
|
|
126
|
+
if (!key || !normalizeAffinityComponent(accountId)) return;
|
|
127
|
+
bindSessionAffinity(plugin.poolKey, key, accountId, now);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve which account should serve this session.
|
|
132
|
+
* Does not call setActiveAccount — callers promote after a usable token.
|
|
133
|
+
*/
|
|
134
|
+
export function resolvePoolAccount(
|
|
135
|
+
plugin: AccountPoolPlugin,
|
|
136
|
+
sessionKey: string | null,
|
|
137
|
+
opts: {
|
|
138
|
+
strategy: "quota" | "round-robin" | "fill-first";
|
|
139
|
+
enabled: boolean;
|
|
140
|
+
activeAccountId?: string;
|
|
141
|
+
stickyLimit?: number;
|
|
142
|
+
autoSwitchThreshold?: number;
|
|
143
|
+
},
|
|
144
|
+
now = Date.now(),
|
|
145
|
+
): { accountId: string | null; reason: AccountPoolPickReason } {
|
|
146
|
+
const allEligible = plugin.listEligibleAccountIds(now);
|
|
147
|
+
if (allEligible.length === 0) {
|
|
148
|
+
return { accountId: null, reason: "none" };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!opts.enabled) {
|
|
152
|
+
const active = opts.activeAccountId;
|
|
153
|
+
if (active && allEligible.includes(active)) {
|
|
154
|
+
return { accountId: active, reason: "disabled" };
|
|
155
|
+
}
|
|
156
|
+
return { accountId: active ?? null, reason: active ? "disabled" : "none" };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const key = normalizeAffinityComponent(sessionKey);
|
|
160
|
+
if (key) {
|
|
161
|
+
const affined = getSessionAffinity(plugin.poolKey, key, now);
|
|
162
|
+
if (affined) {
|
|
163
|
+
const stickWait = isRateLimitStickWait(plugin.poolKey, affined.accountId, now);
|
|
164
|
+
const eligible = filterKernelEligible(
|
|
165
|
+
plugin,
|
|
166
|
+
allEligible,
|
|
167
|
+
now,
|
|
168
|
+
stickWait ? affined.accountId : undefined,
|
|
169
|
+
);
|
|
170
|
+
if (eligible.includes(affined.accountId)) {
|
|
171
|
+
touchSessionAffinity(plugin.poolKey, key, now);
|
|
172
|
+
return { accountId: affined.accountId, reason: "affinity" };
|
|
173
|
+
}
|
|
174
|
+
clearSessionAffinityForAccount(plugin.poolKey, affined.accountId);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const kernelEligible = filterKernelEligible(plugin, allEligible, now);
|
|
179
|
+
if (kernelEligible.length === 0) {
|
|
180
|
+
return { accountId: null, reason: anyCooled(plugin, allEligible, now) ? "all-cooled" : "none" };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const strategy = opts.strategy;
|
|
184
|
+
const stickyLimit = normalizeAccountPoolStickyLimit(opts.stickyLimit);
|
|
185
|
+
const threshold = opts.autoSwitchThreshold ?? DEFAULT_AUTO_SWITCH_THRESHOLD;
|
|
186
|
+
const active = opts.activeAccountId;
|
|
187
|
+
const activeOk = active !== undefined && kernelEligible.includes(active);
|
|
188
|
+
|
|
189
|
+
if (strategy === "round-robin") {
|
|
190
|
+
if (!key && activeOk) {
|
|
191
|
+
return { accountId: active, reason: "active" };
|
|
192
|
+
}
|
|
193
|
+
const picked = pickRoundRobinAccount(plugin.poolKey, kernelEligible, stickyLimit);
|
|
194
|
+
if (!picked) {
|
|
195
|
+
return { accountId: null, reason: anyCooled(plugin, allEligible, now) ? "all-cooled" : "none" };
|
|
196
|
+
}
|
|
197
|
+
notePoolRotationSuccess(plugin.poolKey, picked, stickyLimit);
|
|
198
|
+
bindIfPossible(plugin, sessionKey, picked, now);
|
|
199
|
+
return { accountId: picked, reason: "round-robin" };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (strategy === "fill-first") {
|
|
203
|
+
if (!key && activeOk) {
|
|
204
|
+
return { accountId: active, reason: "active" };
|
|
205
|
+
}
|
|
206
|
+
const picked = pickFillFirst(kernelEligible, active);
|
|
207
|
+
if (!picked) {
|
|
208
|
+
return { accountId: null, reason: anyCooled(plugin, allEligible, now) ? "all-cooled" : "none" };
|
|
209
|
+
}
|
|
210
|
+
bindIfPossible(plugin, sessionKey, picked, now);
|
|
211
|
+
return { accountId: picked, reason: "fill-first" };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// quota strategy
|
|
215
|
+
let accountId: string | null = null;
|
|
216
|
+
let reason: AccountPoolPickReason = "none";
|
|
217
|
+
|
|
218
|
+
if (plugin.usageScore && threshold > 0) {
|
|
219
|
+
if (activeOk) {
|
|
220
|
+
const activeScore = usageScore(plugin, active);
|
|
221
|
+
const hasKnown = activeScore < UNKNOWN_USAGE_SCORE;
|
|
222
|
+
if (!hasKnown || activeScore < threshold) {
|
|
223
|
+
accountId = active;
|
|
224
|
+
reason = "active";
|
|
225
|
+
} else {
|
|
226
|
+
const picked = pickLowestUsage(plugin, kernelEligible);
|
|
227
|
+
if (picked) {
|
|
228
|
+
accountId = picked;
|
|
229
|
+
reason = activeOk && picked === active ? "active" : "lowest-usage";
|
|
230
|
+
} else if (activeOk) {
|
|
231
|
+
accountId = active;
|
|
232
|
+
reason = "active";
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
const picked = pickLowestUsage(plugin, kernelEligible);
|
|
237
|
+
if (picked) {
|
|
238
|
+
accountId = picked;
|
|
239
|
+
reason = "only-eligible";
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} else if (activeOk) {
|
|
243
|
+
accountId = active;
|
|
244
|
+
reason = "active";
|
|
245
|
+
} else {
|
|
246
|
+
const picked = pickLowestUsage(plugin, kernelEligible, active);
|
|
247
|
+
if (picked) {
|
|
248
|
+
accountId = picked;
|
|
249
|
+
reason = "only-eligible";
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!accountId) {
|
|
254
|
+
return { accountId: null, reason: anyCooled(plugin, allEligible, now) ? "all-cooled" : "none" };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
bindIfPossible(plugin, sessionKey, accountId, now);
|
|
258
|
+
return { accountId, reason };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Record a rate-limit 429, cool the failed account, and pick a failover account.
|
|
263
|
+
* Billing (402) must use recordPoolAccountCooldown with reason billing — not this path.
|
|
264
|
+
* Does not call setActiveAccount.
|
|
265
|
+
*/
|
|
266
|
+
export function rotatePoolAccountOn429(
|
|
267
|
+
plugin: AccountPoolPlugin,
|
|
268
|
+
failedAccountId: string,
|
|
269
|
+
sessionKey: string | null,
|
|
270
|
+
retryAfterHeader: string | null,
|
|
271
|
+
now = Date.now(),
|
|
272
|
+
): string | null {
|
|
273
|
+
const session = failoverKey(sessionKey);
|
|
274
|
+
const failoverMap = getFailoverMap(plugin.poolKey);
|
|
275
|
+
const prior = failoverMap.get(session) ?? 0;
|
|
276
|
+
if (prior >= ACCOUNT_POOL_MAX_FAILOVERS) return null;
|
|
277
|
+
|
|
278
|
+
const cooldownMs = recordPoolAccountCooldown(
|
|
279
|
+
plugin.poolKey,
|
|
280
|
+
failedAccountId,
|
|
281
|
+
"rate_limit",
|
|
282
|
+
retryAfterHeader,
|
|
283
|
+
now,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
if (cooldownMs <= STICK_WAIT_MAX_MS) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
clearSessionAffinityForAccount(plugin.poolKey, failedAccountId);
|
|
291
|
+
notePoolRotationFailure(plugin.poolKey, failedAccountId);
|
|
292
|
+
|
|
293
|
+
const eligible = filterKernelEligible(
|
|
294
|
+
plugin,
|
|
295
|
+
plugin.listEligibleAccountIds(now).filter(id => id !== failedAccountId),
|
|
296
|
+
now,
|
|
297
|
+
);
|
|
298
|
+
if (eligible.length === 0) return null;
|
|
299
|
+
|
|
300
|
+
const next = pickRoundRobinAccount(plugin.poolKey, eligible, 1) ?? eligible[0] ?? null;
|
|
301
|
+
if (!next) return null;
|
|
302
|
+
|
|
303
|
+
failoverMap.set(session, prior + 1);
|
|
304
|
+
|
|
305
|
+
const affinityKey = normalizeAffinityComponent(sessionKey);
|
|
306
|
+
if (affinityKey && normalizeAffinityComponent(next)) {
|
|
307
|
+
bindSessionAffinity(plugin.poolKey, affinityKey, next, now);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return next;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Pick a failover account after auth death (401/403). Shares the per-session cap with 429 hops.
|
|
315
|
+
* Does not call setActiveAccount.
|
|
316
|
+
*/
|
|
317
|
+
export function rotatePoolAccountOnAuth(
|
|
318
|
+
plugin: AccountPoolPlugin,
|
|
319
|
+
failedAccountId: string,
|
|
320
|
+
sessionKey: string | null,
|
|
321
|
+
now = Date.now(),
|
|
322
|
+
): string | null {
|
|
323
|
+
const session = failoverKey(sessionKey);
|
|
324
|
+
const failoverMap = getFailoverMap(plugin.poolKey);
|
|
325
|
+
const prior = failoverMap.get(session) ?? 0;
|
|
326
|
+
if (prior >= ACCOUNT_POOL_MAX_FAILOVERS) return null;
|
|
327
|
+
|
|
328
|
+
clearSessionAffinityForAccount(plugin.poolKey, failedAccountId);
|
|
329
|
+
notePoolRotationFailure(plugin.poolKey, failedAccountId);
|
|
330
|
+
|
|
331
|
+
const eligible = filterKernelEligible(
|
|
332
|
+
plugin,
|
|
333
|
+
plugin.listEligibleAccountIds(now).filter(id => id !== failedAccountId),
|
|
334
|
+
now,
|
|
335
|
+
);
|
|
336
|
+
if (eligible.length === 0) return null;
|
|
337
|
+
|
|
338
|
+
const next = pickRoundRobinAccount(plugin.poolKey, eligible, 1) ?? eligible[0] ?? null;
|
|
339
|
+
if (!next) return null;
|
|
340
|
+
|
|
341
|
+
failoverMap.set(session, prior + 1);
|
|
342
|
+
|
|
343
|
+
const affinityKey = normalizeAffinityComponent(sessionKey);
|
|
344
|
+
if (affinityKey && normalizeAffinityComponent(next)) {
|
|
345
|
+
bindSessionAffinity(plugin.poolKey, affinityKey, next, now);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return next;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function resetPoolFailoverCount(
|
|
352
|
+
plugin: AccountPoolPlugin,
|
|
353
|
+
sessionKey: string | null,
|
|
354
|
+
): void {
|
|
355
|
+
getFailoverMap(plugin.poolKey).delete(failoverKey(sessionKey));
|
|
356
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const ACCOUNT_POOL_MAX_FAILOVERS = 3;
|
|
2
|
+
|
|
3
|
+
export type AccountPoolPickReason =
|
|
4
|
+
| "affinity"
|
|
5
|
+
| "active"
|
|
6
|
+
| "lowest-usage"
|
|
7
|
+
| "round-robin"
|
|
8
|
+
| "fill-first"
|
|
9
|
+
| "only-eligible"
|
|
10
|
+
| "none"
|
|
11
|
+
| "all-cooled"
|
|
12
|
+
| "disabled";
|
|
13
|
+
|
|
14
|
+
export interface AccountPoolPlugin {
|
|
15
|
+
readonly poolKey: string;
|
|
16
|
+
sessionKeyFromRequest(input: {
|
|
17
|
+
sessionIdHeader?: string | null;
|
|
18
|
+
threadIdHeader?: string | null;
|
|
19
|
+
clientThreadId?: string | null;
|
|
20
|
+
promptCacheKey?: string | null;
|
|
21
|
+
promptCacheKeyIsSharedCohort?: boolean;
|
|
22
|
+
}): string | null;
|
|
23
|
+
listEligibleAccountIds(now: number): string[];
|
|
24
|
+
usageScore?(accountId: string): number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CooldownRegistry {
|
|
28
|
+
set(accountId: string, until: number, meta?: { source?: string; reason?: string }): void;
|
|
29
|
+
get(accountId: string, now?: number): { until: number; reason?: string; source?: string } | null;
|
|
30
|
+
clear(accountId: string): void;
|
|
31
|
+
sweep(now?: number): number;
|
|
32
|
+
}
|
|
@@ -148,6 +148,9 @@ export function resolveProductionBehaviorValues(
|
|
|
148
148
|
"wire.upstreamProtocol": behaviorRow("provider_config", upstreamProtocol),
|
|
149
149
|
"wire.responsesPath": behaviorRow("provider_config", effective.responsesPath ?? null),
|
|
150
150
|
"wire.commandCodeVersion": behaviorRow("provider_config", effective.commandCodeVersion ?? null),
|
|
151
|
+
...(adapter === "command-code"
|
|
152
|
+
? { "wire.commandCodeProjectContext": behaviorRow("provider_config", effective.projectContext ?? "off") }
|
|
153
|
+
: {}),
|
|
151
154
|
"wire.modelSuffixMode": behaviorRow(
|
|
152
155
|
"provider_config",
|
|
153
156
|
effective.modelSuffixBracketStrip === true ? "bracket_strip" : "none",
|
|
@@ -309,19 +309,29 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
309
309
|
return jsonResponse({ ok: true, provider, activeAccountId: body.accountId });
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
-
// Opt-in
|
|
312
|
+
// Opt-in OAuth account pool settings (Anthropic + Cursor).
|
|
313
313
|
if (url.pathname === "/api/oauth/accounts/pool" && req.method === "GET") {
|
|
314
314
|
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
315
|
-
if (provider
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
315
|
+
if (provider === "anthropic") {
|
|
316
|
+
const pool = config.anthropicAccountPool ?? {};
|
|
317
|
+
return jsonResponse({
|
|
318
|
+
provider,
|
|
319
|
+
enabled: pool.enabled === true,
|
|
320
|
+
autoSwitchThreshold: typeof pool.autoSwitchThreshold === "number" ? pool.autoSwitchThreshold : 80,
|
|
321
|
+
strategy: normalizeAccountPoolStrategy(pool.strategy),
|
|
322
|
+
stickyLimit: normalizeAccountPoolStickyLimit(pool.stickyLimit),
|
|
323
|
+
experimental: true,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
if (provider === "cursor") {
|
|
327
|
+
const pool = config.cursorAccountPool ?? {};
|
|
328
|
+
return jsonResponse({
|
|
329
|
+
provider,
|
|
330
|
+
enabled: pool.enabled === true,
|
|
331
|
+
experimental: true,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return jsonResponse({ error: "pool config is only supported for anthropic or cursor" }, 400);
|
|
325
335
|
}
|
|
326
336
|
if (url.pathname === "/api/oauth/accounts/pool" && (req.method === "PUT" || req.method === "PATCH")) {
|
|
327
337
|
const parsedBody = await readManagementJsonBodyOr(req, {});
|
|
@@ -336,7 +346,32 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
336
346
|
stickyLimit?: unknown;
|
|
337
347
|
};
|
|
338
348
|
const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
|
|
339
|
-
if (provider !== "anthropic"
|
|
349
|
+
if (provider !== "anthropic" && provider !== "cursor") {
|
|
350
|
+
return jsonResponse({ error: "pool config is only supported for anthropic or cursor" }, 400);
|
|
351
|
+
}
|
|
352
|
+
if (provider === "cursor") {
|
|
353
|
+
if (
|
|
354
|
+
body.autoSwitchThreshold !== undefined
|
|
355
|
+
|| body.strategy !== undefined
|
|
356
|
+
|| body.stickyLimit !== undefined
|
|
357
|
+
) {
|
|
358
|
+
return jsonResponse({ error: "cursor pool only supports enabled" }, 400);
|
|
359
|
+
}
|
|
360
|
+
let enabled = config.cursorAccountPool?.enabled === true;
|
|
361
|
+
if (body.enabled !== undefined) {
|
|
362
|
+
if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
|
|
363
|
+
enabled = body.enabled;
|
|
364
|
+
}
|
|
365
|
+
config.cursorAccountPool = { enabled };
|
|
366
|
+
saveConfigPreservingClaudeCode(config);
|
|
367
|
+
reconcileLiveStateStores();
|
|
368
|
+
return jsonResponse({
|
|
369
|
+
ok: true,
|
|
370
|
+
provider,
|
|
371
|
+
enabled,
|
|
372
|
+
experimental: true,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
340
375
|
let enabled = config.anthropicAccountPool?.enabled === true;
|
|
341
376
|
if (body.enabled !== undefined) {
|
|
342
377
|
if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
|