@bitkyc08/opencodex 2.69.0 → 2.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/App-_MrbTrBe.js +51 -0
- package/gui/dist/assets/{Tray-uqKRqfvw.js → Tray-5PuUalET.js} +1 -1
- package/gui/dist/assets/index-DKrO2eF3.js +86 -0
- package/gui/dist/assets/quota-summary-BCLivc0Q.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic-model-contract.ts +119 -0
- package/src/adapters/anthropic.ts +14 -83
- package/src/adapters/base.ts +2 -0
- package/src/adapters/command-code-project-context.ts +542 -0
- package/src/adapters/command-code-tool-text.ts +99 -67
- package/src/adapters/command-code.ts +5 -1
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/devin/cloud-direct/catalog.ts +91 -0
- package/src/adapters/devin/cloud-direct/chat.ts +78 -48
- package/src/adapters/devin/cloud-direct/tool-schema.ts +121 -0
- package/src/adapters/devin/context-overflow.ts +75 -0
- package/src/adapters/devin/live-models.ts +174 -13
- package/src/adapters/devin/reasoning-signature.ts +100 -0
- package/src/adapters/devin.ts +310 -117
- package/src/adapters/kiro/payload.ts +10 -10
- package/src/adapters/kiro-images.ts +58 -7
- package/src/adapters/openai-chat/passthrough.ts +3 -1
- package/src/adapters/openai-chat.ts +2 -1
- package/src/adapters/openai-responses/passthrough.ts +2 -1
- package/src/bridge/response-json.ts +15 -1
- package/src/bridge/sse.ts +3 -1
- package/src/claude/intercept/model-bindings.ts +1 -0
- package/src/cli/account-extended.ts +31 -1
- package/src/cli/account.ts +3 -0
- package/src/cli/capabilities.ts +13 -0
- package/src/cli/client-start-fence.ts +36 -0
- package/src/cli/dispatch.ts +3 -2
- package/src/cli/index.ts +98 -117
- package/src/cli/provider-runtime.ts +23 -2
- package/src/cli/provider.ts +3 -0
- package/src/cli/resolve.ts +18 -0
- package/src/cli/start-owner-fence.ts +30 -0
- package/src/cli/stop-approval.ts +13 -1
- package/src/cli/stop-restore.ts +73 -0
- package/src/cli/tray-proxy.ts +221 -18
- package/src/cli/version-skew.ts +25 -23
- package/src/client/runtime.ts +8 -4
- package/src/codex/auth-api/account-list.ts +11 -5
- package/src/codex/auth-api/login-flow.ts +5 -3
- package/src/codex/auth-api/main-account-probe.ts +186 -110
- package/src/codex/auth-api/pool-mode-gate.ts +61 -7
- package/src/codex/auth-api/pool-quota-probe.ts +142 -90
- package/src/codex/auth-api/reset-credit-service.ts +1 -1
- package/src/codex/catalog/gather-capture.ts +1 -0
- package/src/codex/catalog/model-hints.ts +3 -3
- package/src/codex/catalog/provider-models.ts +2 -0
- package/src/codex/low-quota-events.ts +28 -0
- package/src/codex/low-quota-observer.ts +23 -0
- package/src/codex/low-quota-protection.ts +272 -0
- package/src/codex/quota-query-backoff.ts +148 -0
- package/src/codex/quota.ts +7 -2
- package/src/config/diagnostics.ts +10 -1
- package/src/config/load-degrade.ts +17 -0
- package/src/config/mutation-lock.ts +3 -4
- package/src/config/process-state.ts +1 -1
- package/src/config/provider-validation.ts +22 -0
- package/src/config/schema/blocked-model-redirects.ts +17 -0
- package/src/config/schema/config-schema.ts +4 -1
- package/src/config/schema/leaf-validators.ts +33 -2
- package/src/config/serving-runtimes.ts +412 -0
- package/src/generated/compatibility-version.json +211 -115
- package/src/generated/model-metadata.ts +3 -3
- package/src/images/loop.ts +3 -0
- package/src/lib/errors.ts +2 -1
- package/src/lib/handled-signal-exit.ts +23 -0
- package/src/lib/request-failure-model.ts +1 -0
- package/src/lib/self-launch-argv.ts +9 -0
- package/src/lib/shadow-call.ts +5 -2
- package/src/lib/strict-semver.ts +26 -0
- package/src/lib/transient-service-path.ts +16 -0
- package/src/lib/windows-service-wrappers.ts +10 -6
- package/src/lib/winsw.ts +3 -1
- package/src/oauth/anthropic-model-routes.ts +105 -0
- package/src/oauth/anthropic-routing.ts +85 -48
- package/src/oauth/devin.ts +135 -5
- package/src/oauth/index.ts +20 -3
- package/src/oauth/kiro-terminal-failover.ts +23 -9
- package/src/oauth/pool-settings-capability.ts +10 -2
- package/src/oauth/store.ts +1 -1
- package/src/protocols/encoders/adapter-events.ts +3 -1
- package/src/providers/github-copilot-context.ts +27 -0
- package/src/providers/kiro-models.ts +4 -0
- package/src/providers/model-rename-fields.ts +3 -0
- package/src/providers/quota/account-cache.ts +8 -3
- package/src/providers/quota/devin.ts +286 -0
- package/src/providers/quota/vendor-probes-oauth.ts +6 -2
- package/src/providers/quota.ts +1 -1
- package/src/providers/registry/entries-core.ts +1 -1
- package/src/providers/registry/model-seeds.ts +5 -2
- package/src/responses/compaction-images.ts +27 -0
- package/src/router.ts +104 -16
- package/src/routing/analytics.ts +1 -0
- package/src/server/auth-cors.ts +9 -1
- package/src/server/background-lifecycle.ts +21 -10
- package/src/server/chat-completions.ts +2 -1
- package/src/server/chat-native.ts +1 -0
- package/src/server/index.ts +4 -8
- package/src/server/inference/client-encoder-delivery.ts +3 -0
- package/src/server/management/context.ts +3 -0
- package/src/server/management/low-quota-routes.ts +14 -0
- package/src/server/management/oauth-account-routes.ts +21 -1
- package/src/server/management/provider-overwrite-carry.ts +79 -6
- package/src/server/management/provider-routes.ts +41 -28
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/management-api.ts +7 -0
- package/src/server/responses/adapter-continuation.ts +3 -0
- package/src/server/responses/adapter-delivery.ts +3 -0
- package/src/server/responses/adapter-dispatch.ts +20 -4
- package/src/server/responses/antigravity-validation-refusal.ts +20 -0
- package/src/server/responses/core-auth.ts +1 -0
- package/src/server/responses/core-codex-account.ts +1 -0
- package/src/server/responses/core-normalize.ts +4 -0
- package/src/server/responses/passthrough-dispatch.ts +8 -1
- package/src/server/responses/request-sidecar-auth.ts +2 -0
- package/src/server/responses/request-transport.ts +27 -10
- package/src/server/responses/response-effects.ts +4 -1
- package/src/server/responses/run-turn-execution.ts +126 -42
- package/src/server/responses/sidecar-execution.ts +4 -1
- package/src/service/diagnostics.ts +3 -1
- package/src/service/guarded-manager-target.ts +39 -12
- package/src/service/launchd.ts +14 -6
- package/src/service/repair.ts +6 -1
- package/src/service/service-child-ownership.ts +168 -0
- package/src/service/state.ts +22 -4
- package/src/service/systemd.ts +2 -2
- package/src/service/windows-guarded-manager.ts +530 -0
- package/src/service/windows-taskxml.ts +2 -2
- package/src/service.ts +2 -0
- package/src/types/config.ts +26 -0
- package/src/types/provider.ts +12 -0
- package/src/types/request.ts +2 -0
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +13 -0
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/vision/anthropic-describe.ts +2 -1
- package/src/web-search/anthropic-executor.ts +2 -1
- package/src/web-search/loop.ts +1 -0
- package/gui/dist/assets/App-DDbgOTh6.js +0 -51
- package/gui/dist/assets/index-CuaBJkG4.js +0 -86
- package/gui/dist/assets/quota-summary-COppQSVq.js +0 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { AnthropicModelRoute, OcxConfig } from "../types/config";
|
|
2
|
+
|
|
3
|
+
export interface AnthropicRouteDecision {
|
|
4
|
+
/** One-based position in the saved rule list; safe for request logs. */
|
|
5
|
+
position: number;
|
|
6
|
+
accounts: readonly string[];
|
|
7
|
+
fallback: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type ParseResult = { ok: true; routes: AnthropicModelRoute[] } | { ok: false; error: string };
|
|
11
|
+
export type AnthropicRoutesRead = { routes: AnthropicModelRoute[] | null; routesError?: string };
|
|
12
|
+
const MAX_ROUTES = 32;
|
|
13
|
+
const MAX_ACCOUNTS = 32;
|
|
14
|
+
const MAX_TEXT = 128;
|
|
15
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
16
|
+
const PATTERN = /^[A-Za-z0-9_./:*?-]+$/;
|
|
17
|
+
|
|
18
|
+
/** Validate the stored rule, not current roster membership: re-adding an account restores it. */
|
|
19
|
+
export function parseAnthropicModelRoutes(raw: unknown): ParseResult {
|
|
20
|
+
if (!Array.isArray(raw) || raw.length > MAX_ROUTES) {
|
|
21
|
+
return { ok: false, error: `routes must be an array of at most ${MAX_ROUTES} rules` };
|
|
22
|
+
}
|
|
23
|
+
const names = new Set<string>();
|
|
24
|
+
const patterns = new Set<string>();
|
|
25
|
+
const routes: AnthropicModelRoute[] = [];
|
|
26
|
+
for (const [index, value] of raw.entries()) {
|
|
27
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false, error: `routes[${index}] must be an object` };
|
|
28
|
+
const row = value as Record<string, unknown>;
|
|
29
|
+
if (Object.keys(row).some(key => !["name", "match", "accounts", "fallback"].includes(key))) {
|
|
30
|
+
return { ok: false, error: `routes[${index}] has an unknown field` };
|
|
31
|
+
}
|
|
32
|
+
if (typeof row.name !== "string" || !NAME.test(row.name) || names.has(row.name)) {
|
|
33
|
+
return { ok: false, error: `routes[${index}].name must be unique, nonempty and at most 64 safe characters` };
|
|
34
|
+
}
|
|
35
|
+
if (typeof row.match !== "string" || !row.match || row.match.length > MAX_TEXT
|
|
36
|
+
|| !PATTERN.test(row.match) || patterns.has(row.match)) {
|
|
37
|
+
return { ok: false, error: `routes[${index}].match must be a unique model glob of at most ${MAX_TEXT} characters` };
|
|
38
|
+
}
|
|
39
|
+
if (!Array.isArray(row.accounts) || row.accounts.length < 1 || row.accounts.length > MAX_ACCOUNTS
|
|
40
|
+
|| row.accounts.some(id => typeof id !== "string" || !id || id.length > MAX_TEXT || id.trim() !== id || /[\x00-\x1f\x7f]/.test(id))
|
|
41
|
+
|| new Set(row.accounts).size !== row.accounts.length) {
|
|
42
|
+
return { ok: false, error: `routes[${index}].accounts must contain 1-${MAX_ACCOUNTS} unique stored IDs` };
|
|
43
|
+
}
|
|
44
|
+
if (row.fallback !== undefined && typeof row.fallback !== "boolean") {
|
|
45
|
+
return { ok: false, error: `routes[${index}].fallback must be boolean` };
|
|
46
|
+
}
|
|
47
|
+
names.add(row.name);
|
|
48
|
+
patterns.add(row.match);
|
|
49
|
+
routes.push({ name: row.name, match: row.match, accounts: [...row.accounts], ...(row.fallback === undefined ? {} : { fallback: row.fallback }) });
|
|
50
|
+
}
|
|
51
|
+
return { ok: true, routes };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A malformed hand edit stays on disk for correction, but must never look valid to readers. */
|
|
55
|
+
export function readAnthropicModelRoutes(raw: unknown): AnthropicRoutesRead {
|
|
56
|
+
if (raw === undefined) return { routes: null };
|
|
57
|
+
const parsed = parseAnthropicModelRoutes(raw);
|
|
58
|
+
return parsed.ok ? { routes: parsed.routes } : { routes: null, routesError: parsed.error };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function matches(pattern: string, modelId: string): boolean {
|
|
62
|
+
// Linear wildcard matching avoids regex backtracking on operator-declared glob strings.
|
|
63
|
+
let p = 0;
|
|
64
|
+
let m = 0;
|
|
65
|
+
let star = -1;
|
|
66
|
+
let resume = 0;
|
|
67
|
+
while (m < modelId.length) {
|
|
68
|
+
if (p < pattern.length && (pattern[p] === modelId[m] || pattern[p] === "?")) {
|
|
69
|
+
p++; m++;
|
|
70
|
+
} else if (pattern[p] === "*") {
|
|
71
|
+
star = p++;
|
|
72
|
+
resume = m;
|
|
73
|
+
} else if (star >= 0) {
|
|
74
|
+
p = star + 1;
|
|
75
|
+
m = ++resume;
|
|
76
|
+
} else return false;
|
|
77
|
+
}
|
|
78
|
+
while (pattern[p] === "*") p++;
|
|
79
|
+
return p === pattern.length;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function resolveAnthropicModelRoute(
|
|
83
|
+
config: OcxConfig,
|
|
84
|
+
modelId: string,
|
|
85
|
+
): { decision: AnthropicRouteDecision | null; error?: string } {
|
|
86
|
+
if (config.anthropicAccountPool?.enabled !== true) return { decision: null };
|
|
87
|
+
const raw = (config.anthropicAccountPool as { routes?: unknown } | undefined)?.routes;
|
|
88
|
+
if (raw === undefined) return { decision: null };
|
|
89
|
+
const parsed = parseAnthropicModelRoutes(raw);
|
|
90
|
+
if (!parsed.ok) return { decision: null, error: parsed.error };
|
|
91
|
+
for (const [index, route] of parsed.routes.entries()) {
|
|
92
|
+
if (matches(route.match, modelId)) {
|
|
93
|
+
return { decision: { position: index + 1, accounts: route.accounts, fallback: route.fallback === true } };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { decision: null };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Preserve declared account order; widen only when the routed set is empty. */
|
|
100
|
+
export function routeCandidates(eligible: readonly string[], decision: AnthropicRouteDecision | null): string[] {
|
|
101
|
+
if (!decision) return [...eligible];
|
|
102
|
+
const available = new Set(eligible);
|
|
103
|
+
const routed = decision.accounts.filter(id => available.has(id));
|
|
104
|
+
return routed.length > 0 || !decision.fallback ? routed : [...eligible];
|
|
105
|
+
}
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConfig } from "../types";
|
|
34
34
|
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
35
35
|
import { retainedUtf8Bytes } from "../lib/admission";
|
|
36
|
+
import { routeCandidates, type AnthropicRouteDecision } from "./anthropic-model-routes";
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* The read side of a `Headers` object, so a caller can pass the live upstream response's
|
|
@@ -54,17 +55,7 @@ const VALID_QUOTA_WINDOWS = new Set<OcxAccountPoolQuotaWindow>(["five-hour", "we
|
|
|
54
55
|
/** Cap same-request 429 rotations so short Retry-After cannot infinite-loop. */
|
|
55
56
|
export const ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST = 3;
|
|
56
57
|
|
|
57
|
-
export
|
|
58
|
-
enabled?: boolean;
|
|
59
|
-
/** Usage % for new-session pick. Default 80. 0 = disable quota-based pick (active / affinity only). */
|
|
60
|
-
autoSwitchThreshold?: number;
|
|
61
|
-
/** New-session rotation strategy. Default quota (today's behaviour). */
|
|
62
|
-
strategy?: OcxAccountPoolRotationStrategy;
|
|
63
|
-
/** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */
|
|
64
|
-
stickyLimit?: number;
|
|
65
|
-
/** Usage window for quota-based scoring. Default "five-hour" (today's behaviour). */
|
|
66
|
-
quotaWindow?: OcxAccountPoolQuotaWindow;
|
|
67
|
-
}
|
|
58
|
+
export type AnthropicAccountPoolConfig = NonNullable<OcxConfig["anthropicAccountPool"]>;
|
|
68
59
|
|
|
69
60
|
/**
|
|
70
61
|
* Where a cooldown's length came from. Same vocabulary as `CodexCooldownSource`, because it
|
|
@@ -355,11 +346,17 @@ export function forgetAnthropicFailoverQuorum(): void {
|
|
|
355
346
|
}
|
|
356
347
|
|
|
357
348
|
/** Earliest remaining cooldown among cooled Anthropic accounts, for client Retry-After. */
|
|
358
|
-
export function getAnthropicPoolRetryAfterSeconds(now = Date.now()): number | null {
|
|
349
|
+
export function getAnthropicPoolRetryAfterSeconds(now = Date.now(), decision: AnthropicRouteDecision | null = null): number | null {
|
|
359
350
|
const set = getAccountSet(PROVIDER);
|
|
360
351
|
if (!set) return null;
|
|
352
|
+
// Once an explicit fallback route has no eligible declared account, selection may use
|
|
353
|
+
// the ordinary pool. Its earliest usable cooldown must determine the advertised wait.
|
|
354
|
+
const fallbackExpanded = decision?.fallback === true
|
|
355
|
+
&& !getEligibleAnthropicAccounts(now).some(id => decision.accounts.includes(id));
|
|
361
356
|
let earliest: number | null = null;
|
|
362
357
|
for (const account of set.accounts) {
|
|
358
|
+
if (decision && !fallbackExpanded && !decision.accounts.includes(account.id)) continue;
|
|
359
|
+
if (fallbackExpanded && (account.needsReauth === true || !isPoolCredentialUsable(account.id, now))) continue;
|
|
363
360
|
const snap = getAnthropicAccountHealthSnapshot(account.id, now);
|
|
364
361
|
if (!snap?.cooldownUntil) continue;
|
|
365
362
|
if (earliest === null || snap.cooldownUntil < earliest) earliest = snap.cooldownUntil;
|
|
@@ -388,9 +385,9 @@ function compareScoredAccounts(a: ScoredAccount, b: ScoredAccount): number {
|
|
|
388
385
|
return a.score - b.score || a.fiveHourTieBreak - b.fiveHourTieBreak;
|
|
389
386
|
}
|
|
390
387
|
|
|
391
|
-
function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number): string | null {
|
|
388
|
+
function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number, decision: AnthropicRouteDecision | null = null): string | null {
|
|
392
389
|
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
393
|
-
const unfiltered = getEligibleAnthropicAccounts(now).filter(id => id !== excludeId);
|
|
390
|
+
const unfiltered = routeCandidates(getEligibleAnthropicAccounts(now), decision).filter(id => id !== excludeId);
|
|
394
391
|
const available = window === "weekly" ? unfiltered.filter(id => !exhausted5h(id)) : unfiltered;
|
|
395
392
|
const eligible = available.length > 0 ? available : unfiltered;
|
|
396
393
|
if (eligible.length === 0) return null;
|
|
@@ -412,19 +409,26 @@ function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now:
|
|
|
412
409
|
return best.accountId;
|
|
413
410
|
}
|
|
414
411
|
|
|
412
|
+
/** A fallback route uses ordinary ordering only after it widens beyond its declared accounts. */
|
|
413
|
+
function usesDeclaredRouteOrder(eligible: readonly string[], decision: AnthropicRouteDecision | null): boolean {
|
|
414
|
+
return decision !== null && (!decision.fallback || eligible.some(id => decision.accounts.includes(id)));
|
|
415
|
+
}
|
|
416
|
+
|
|
415
417
|
/** Next eligible Anthropic account in stable order after `afterId` (wrapping). */
|
|
416
418
|
function pickNextFillFirstAnthropicAccount(
|
|
417
419
|
config: OcxConfig,
|
|
418
420
|
afterId: string,
|
|
419
421
|
eligible: string[],
|
|
422
|
+
decision: AnthropicRouteDecision | null,
|
|
420
423
|
): string | null {
|
|
421
424
|
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
422
425
|
const available = window === "weekly" ? eligible.filter(id => !exhausted5h(id)) : eligible;
|
|
423
426
|
const candidates = available.length > 0 ? available : eligible;
|
|
424
427
|
if (candidates.length === 0) return null;
|
|
425
|
-
const
|
|
428
|
+
const routeOrder = usesDeclaredRouteOrder(eligible, decision);
|
|
429
|
+
const ordered = routeOrder ? candidates : [...candidates].sort((a, b) => a.localeCompare(b));
|
|
426
430
|
const set = getAccountSet(PROVIDER);
|
|
427
|
-
const stableAll = set
|
|
431
|
+
const stableAll = routeOrder ? [...decision!.accounts] : set
|
|
428
432
|
? [...set.accounts.map(a => a.id)].sort((a, b) => a.localeCompare(b))
|
|
429
433
|
: ordered;
|
|
430
434
|
const startIdx = stableAll.indexOf(afterId);
|
|
@@ -449,16 +453,17 @@ function pickAlternateAnthropicAccount(
|
|
|
449
453
|
config: OcxConfig,
|
|
450
454
|
excludeId: string,
|
|
451
455
|
now: number,
|
|
456
|
+
decision: AnthropicRouteDecision | null,
|
|
452
457
|
): string | null {
|
|
453
458
|
const strategy = anthropicPoolStrategy(config);
|
|
454
|
-
const eligible = getEligibleAnthropicAccounts(now).filter(id => id !== excludeId);
|
|
459
|
+
const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision).filter(id => id !== excludeId);
|
|
455
460
|
if (strategy === "round-robin") {
|
|
456
461
|
return peekRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, stickyLimitForPool(config));
|
|
457
462
|
}
|
|
458
463
|
if (strategy === "fill-first") {
|
|
459
|
-
return pickNextFillFirstAnthropicAccount(config, excludeId, eligible);
|
|
464
|
+
return pickNextFillFirstAnthropicAccount(config, excludeId, eligible, decision);
|
|
460
465
|
}
|
|
461
|
-
return pickLowestUsage(config, excludeId, now);
|
|
466
|
+
return pickLowestUsage(config, excludeId, now, decision);
|
|
462
467
|
}
|
|
463
468
|
|
|
464
469
|
function pruneExpiredAffinity(now: number): void {
|
|
@@ -486,6 +491,7 @@ export type AnthropicAccountSelectionReason =
|
|
|
486
491
|
export interface AnthropicAccountSelection {
|
|
487
492
|
accountId: string | null;
|
|
488
493
|
reason: AnthropicAccountSelectionReason;
|
|
494
|
+
routePosition?: number;
|
|
489
495
|
}
|
|
490
496
|
|
|
491
497
|
function stickyLimitForPool(config: OcxConfig): number {
|
|
@@ -510,8 +516,8 @@ function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string):
|
|
|
510
516
|
* Fill-first: keep eligible active under threshold; otherwise advance to the next
|
|
511
517
|
* eligible id in stable sorted order after the current active (wrapping).
|
|
512
518
|
*/
|
|
513
|
-
function pickFillFirstAnthropicAccount(config: OcxConfig, now: number): string | null {
|
|
514
|
-
const eligible = getEligibleAnthropicAccounts(now);
|
|
519
|
+
function pickFillFirstAnthropicAccount(config: OcxConfig, now: number, decision: AnthropicRouteDecision | null): string | null {
|
|
520
|
+
const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision);
|
|
515
521
|
if (eligible.length === 0) return null;
|
|
516
522
|
|
|
517
523
|
const set = getAccountSet(PROVIDER);
|
|
@@ -521,14 +527,14 @@ function pickFillFirstAnthropicAccount(config: OcxConfig, now: number): string |
|
|
|
521
527
|
}
|
|
522
528
|
|
|
523
529
|
if (!active || !set) {
|
|
524
|
-
const ordered = [...eligible].sort((a, b) => a.localeCompare(b));
|
|
530
|
+
const ordered = usesDeclaredRouteOrder(eligible, decision) ? eligible : [...eligible].sort((a, b) => a.localeCompare(b));
|
|
525
531
|
for (const id of ordered) {
|
|
526
532
|
if (isActiveUnderFillFirstThreshold(config, id)) return id;
|
|
527
533
|
}
|
|
528
534
|
return ordered[0] ?? null;
|
|
529
535
|
}
|
|
530
536
|
|
|
531
|
-
return pickNextFillFirstAnthropicAccount(config, active, eligible);
|
|
537
|
+
return pickNextFillFirstAnthropicAccount(config, active, eligible, decision);
|
|
532
538
|
}
|
|
533
539
|
|
|
534
540
|
/**
|
|
@@ -538,12 +544,13 @@ function pickFillFirstAnthropicAccount(config: OcxConfig, now: number): string |
|
|
|
538
544
|
function pickUnboundStrategyAccount(
|
|
539
545
|
config: OcxConfig,
|
|
540
546
|
now: number,
|
|
547
|
+
decision: AnthropicRouteDecision | null,
|
|
541
548
|
): { accountId: string; reason: "round-robin" | "fill-first" } | null {
|
|
542
549
|
const strategy = anthropicPoolStrategy(config);
|
|
543
550
|
if (strategy === "quota") return null;
|
|
544
551
|
|
|
545
552
|
if (strategy === "round-robin") {
|
|
546
|
-
const eligible = getEligibleAnthropicAccounts(now);
|
|
553
|
+
const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision);
|
|
547
554
|
const limit = stickyLimitForPool(config);
|
|
548
555
|
const picked = peekRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, limit);
|
|
549
556
|
if (!picked) return null;
|
|
@@ -551,7 +558,7 @@ function pickUnboundStrategyAccount(
|
|
|
551
558
|
}
|
|
552
559
|
|
|
553
560
|
if (strategy === "fill-first") {
|
|
554
|
-
const picked = pickFillFirstAnthropicAccount(config, now);
|
|
561
|
+
const picked = pickFillFirstAnthropicAccount(config, now, decision);
|
|
555
562
|
if (!picked) return null;
|
|
556
563
|
return { accountId: picked, reason: "fill-first" };
|
|
557
564
|
}
|
|
@@ -567,10 +574,11 @@ export function resolveAnthropicAccountForSession(
|
|
|
567
574
|
sessionKey: string | null | undefined,
|
|
568
575
|
config: OcxConfig,
|
|
569
576
|
now = Date.now(),
|
|
577
|
+
decision: AnthropicRouteDecision | null = null,
|
|
570
578
|
): AnthropicAccountSelection {
|
|
571
579
|
pruneExpiredAffinity(now);
|
|
572
580
|
const set = getAccountSet(PROVIDER);
|
|
573
|
-
if (!set || set.accounts.length === 0) return { accountId: null, reason: "none" };
|
|
581
|
+
if (!set || set.accounts.length === 0) return { accountId: null, reason: "none", ...(decision ? { routePosition: decision.position } : {}) };
|
|
574
582
|
|
|
575
583
|
if (manualPreference === undefined) {
|
|
576
584
|
manualPreference = set.selectionRevision !== undefined
|
|
@@ -582,6 +590,21 @@ export function resolveAnthropicAccountForSession(
|
|
|
582
590
|
return { accountId: set.activeAccountId, reason: "pool-disabled" };
|
|
583
591
|
}
|
|
584
592
|
|
|
593
|
+
const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision);
|
|
594
|
+
if (decision && eligible.length === 0) {
|
|
595
|
+
let cooled: boolean;
|
|
596
|
+
if (decision.fallback) {
|
|
597
|
+
// A removed route member is not a candidate, but fallback can still use the
|
|
598
|
+
// ordinary pool once those accounts recover. Only usable stored accounts count.
|
|
599
|
+
const ordinary = set.accounts.filter(account =>
|
|
600
|
+
account.needsReauth !== true && isPoolCredentialUsable(account.id, now));
|
|
601
|
+
cooled = ordinary.length > 0 && ordinary.every(account => isCooled(account.id, now));
|
|
602
|
+
} else {
|
|
603
|
+
cooled = decision.accounts.every(id => set.accounts.some(account => account.id === id && isCooled(id, now)));
|
|
604
|
+
}
|
|
605
|
+
return { accountId: null, reason: cooled ? "all-cooled" : "none", routePosition: decision.position };
|
|
606
|
+
}
|
|
607
|
+
|
|
585
608
|
// A manual choice is a one-dispatch preference, not a lower-priority quota hint.
|
|
586
609
|
// Consume it only after admission commits, so a failed token lookup cannot spend it.
|
|
587
610
|
if (manualPreference) {
|
|
@@ -593,8 +616,8 @@ export function resolveAnthropicAccountForSession(
|
|
|
593
616
|
const exhausted = [quota?.fiveHourPercent, quota?.weeklyPercent, quota?.monthlyPercent,
|
|
594
617
|
...(quota?.customWindows ?? []).map(window => window.percent)]
|
|
595
618
|
.some(percent => typeof percent === "number" && percent >= 100);
|
|
596
|
-
if (!exhausted &&
|
|
597
|
-
return { accountId: chosen, reason: "manual" };
|
|
619
|
+
if (!exhausted && eligible.includes(chosen)) {
|
|
620
|
+
return { accountId: chosen, reason: "manual", routePosition: decision?.position };
|
|
598
621
|
}
|
|
599
622
|
}
|
|
600
623
|
}
|
|
@@ -604,10 +627,14 @@ export function resolveAnthropicAccountForSession(
|
|
|
604
627
|
const affined = sessionAffinity.get(key);
|
|
605
628
|
if (affined && now - affined.lastUsedAt <= AFFINITY_IDLE_TTL_MS) {
|
|
606
629
|
const stillThere = set.accounts.some(a => a.id === affined.accountId && a.needsReauth !== true);
|
|
607
|
-
|
|
608
|
-
|
|
630
|
+
const stillUsable = stillThere && !isCooled(affined.accountId, now)
|
|
631
|
+
&& isPoolCredentialUsable(affined.accountId, now);
|
|
632
|
+
if (stillUsable && eligible.includes(affined.accountId)) {
|
|
633
|
+
return { accountId: affined.accountId, reason: "affinity", routePosition: decision?.position };
|
|
609
634
|
}
|
|
610
|
-
|
|
635
|
+
// A model route may exclude a healthy binding only for this request. Keep it for
|
|
636
|
+
// another model; remove bindings only when the account itself became unusable.
|
|
637
|
+
if (!stillUsable) sessionAffinity.delete(key);
|
|
611
638
|
}
|
|
612
639
|
}
|
|
613
640
|
|
|
@@ -618,21 +645,21 @@ export function resolveAnthropicAccountForSession(
|
|
|
618
645
|
if (!key && (strategy === "round-robin" || strategy === "fill-first")) {
|
|
619
646
|
const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true)
|
|
620
647
|
&& !isCooled(set.activeAccountId, now)
|
|
621
|
-
&&
|
|
648
|
+
&& eligible.includes(set.activeAccountId);
|
|
622
649
|
if (activeOk) {
|
|
623
|
-
return { accountId: set.activeAccountId, reason: "active" };
|
|
650
|
+
return { accountId: set.activeAccountId, reason: "active", routePosition: decision?.position };
|
|
624
651
|
}
|
|
625
652
|
}
|
|
626
653
|
|
|
627
|
-
const strategyPick = pickUnboundStrategyAccount(config, now);
|
|
654
|
+
const strategyPick = pickUnboundStrategyAccount(config, now, decision);
|
|
628
655
|
if (strategyPick) {
|
|
629
|
-
return { accountId: strategyPick.accountId, reason: strategyPick.reason };
|
|
656
|
+
return { accountId: strategyPick.accountId, reason: strategyPick.reason, routePosition: decision?.position };
|
|
630
657
|
}
|
|
631
658
|
|
|
632
659
|
const threshold = anthropicAutoSwitchThreshold(config);
|
|
633
660
|
const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true)
|
|
634
661
|
&& !isCooled(set.activeAccountId, now)
|
|
635
|
-
&&
|
|
662
|
+
&& eligible.includes(set.activeAccountId);
|
|
636
663
|
|
|
637
664
|
let accountId: string | null = null;
|
|
638
665
|
let reason: AnthropicAccountSelectionReason = "none";
|
|
@@ -646,7 +673,7 @@ export function resolveAnthropicAccountForSession(
|
|
|
646
673
|
accountId = set.activeAccountId;
|
|
647
674
|
reason = "active";
|
|
648
675
|
} else {
|
|
649
|
-
const picked = pickLowestUsage(config, undefined, now);
|
|
676
|
+
const picked = pickLowestUsage(config, undefined, now, decision);
|
|
650
677
|
if (picked) {
|
|
651
678
|
accountId = picked;
|
|
652
679
|
reason = activeOk && picked === set.activeAccountId ? "active" : "lowest-usage";
|
|
@@ -659,7 +686,7 @@ export function resolveAnthropicAccountForSession(
|
|
|
659
686
|
accountId = set.activeAccountId;
|
|
660
687
|
reason = "active";
|
|
661
688
|
} else {
|
|
662
|
-
const picked = pickLowestUsage(config, set.activeAccountId, now);
|
|
689
|
+
const picked = pickLowestUsage(config, set.activeAccountId, now, decision);
|
|
663
690
|
if (picked) {
|
|
664
691
|
accountId = picked;
|
|
665
692
|
reason = "only-eligible";
|
|
@@ -667,11 +694,11 @@ export function resolveAnthropicAccountForSession(
|
|
|
667
694
|
}
|
|
668
695
|
|
|
669
696
|
if (!accountId) {
|
|
670
|
-
const anyCooled = set.accounts.some(a => isCooled(a.id, now));
|
|
671
|
-
return { accountId: null, reason: anyCooled ? "all-cooled" : "none" };
|
|
697
|
+
const anyCooled = set.accounts.some(a => (!decision || decision.accounts.includes(a.id)) && isCooled(a.id, now));
|
|
698
|
+
return { accountId: null, reason: anyCooled ? "all-cooled" : "none", routePosition: decision?.position };
|
|
672
699
|
}
|
|
673
700
|
|
|
674
|
-
return { accountId, reason };
|
|
701
|
+
return { accountId, reason, routePosition: decision?.position };
|
|
675
702
|
}
|
|
676
703
|
|
|
677
704
|
export function bindAnthropicSessionAffinity(
|
|
@@ -707,6 +734,7 @@ export function rotateAnthropicAccountOn429(
|
|
|
707
734
|
sessionKey?: string | null,
|
|
708
735
|
now = Date.now(),
|
|
709
736
|
rateLimitHeaders?: AnthropicRateLimitHeaders | null,
|
|
737
|
+
decision: AnthropicRouteDecision | null = null,
|
|
710
738
|
): string | null {
|
|
711
739
|
// Reactive 429 failover is NOT gated on the pool flag. That flag buys PROACTIVE routing --
|
|
712
740
|
// session affinity, quota-ranked new-session selection, autoSwitchThreshold, strategy -- all
|
|
@@ -741,15 +769,15 @@ export function rotateAnthropicAccountOn429(
|
|
|
741
769
|
// because those dormant values remain in config. The quota picker is the neutral
|
|
742
770
|
// recovery policy already used by the default strategy.
|
|
743
771
|
const next = isAnthropicAccountPoolEnabled(config)
|
|
744
|
-
? pickAlternateAnthropicAccount(config, failedAccountId, now)
|
|
772
|
+
? pickAlternateAnthropicAccount(config, failedAccountId, now, decision)
|
|
745
773
|
: pickLowestUsage(config, failedAccountId, now);
|
|
746
774
|
if (!next) {
|
|
747
|
-
console.warn(
|
|
775
|
+
console.warn(`[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}no eligible replacement; returning 429`);
|
|
748
776
|
return null;
|
|
749
777
|
}
|
|
750
778
|
|
|
751
779
|
console.warn(
|
|
752
|
-
`[anthropic-pool] 429 on ${formatAnthropicAccountOrdinal(failedAccountId)}; failing over to ${formatAnthropicAccountOrdinal(next)}`,
|
|
780
|
+
`[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}429 on ${formatAnthropicAccountOrdinal(failedAccountId)}; failing over to ${formatAnthropicAccountOrdinal(next)}`,
|
|
753
781
|
);
|
|
754
782
|
return next;
|
|
755
783
|
}
|
|
@@ -759,6 +787,7 @@ export interface AnthropicSelectionRoutingOptions {
|
|
|
759
787
|
sessionKey?: string | null;
|
|
760
788
|
reason?: AnthropicAccountSelectionReason;
|
|
761
789
|
expectedCredentialGeneration?: string;
|
|
790
|
+
routeDecision?: AnthropicRouteDecision | null;
|
|
762
791
|
}
|
|
763
792
|
|
|
764
793
|
/** Commit the selected account before dispatch; rejected proposals have no routing side effects. */
|
|
@@ -767,7 +796,7 @@ export async function promoteAnthropicActiveAccount(
|
|
|
767
796
|
expectedSelection: OAuthAccountSelection | null,
|
|
768
797
|
options: AnthropicSelectionRoutingOptions,
|
|
769
798
|
): Promise<OAuthAccountSelection | null> {
|
|
770
|
-
if (!expectedSelection || !
|
|
799
|
+
if (!expectedSelection || !routeCandidates(getEligibleAnthropicAccounts(), options.routeDecision ?? null).includes(accountId)) return null;
|
|
771
800
|
const committed = await commitOAuthAccountSelection(PROVIDER, accountId, {
|
|
772
801
|
expectedSelection,
|
|
773
802
|
expectedCredentialGeneration: options.expectedCredentialGeneration,
|
|
@@ -784,17 +813,25 @@ export function commitAnthropicSelectionRouting(
|
|
|
784
813
|
committed: OAuthAccountSelection,
|
|
785
814
|
options: AnthropicSelectionRoutingOptions,
|
|
786
815
|
): boolean {
|
|
787
|
-
if (committed.accountId !== accountId
|
|
816
|
+
if (committed.accountId !== accountId || (options.routeDecision
|
|
817
|
+
&& !routeCandidates(getEligibleAnthropicAccounts(), options.routeDecision).includes(accountId))) return false;
|
|
788
818
|
const current = captureOAuthAccountSelection(PROVIDER);
|
|
789
819
|
if (current?.accountId !== committed.accountId || current.revision !== committed.revision) return false;
|
|
790
820
|
if (isAnthropicAccountPoolEnabled(options.config)) {
|
|
791
821
|
if (anthropicPoolStrategy(options.config) === "round-robin" && options.reason !== "affinity") {
|
|
792
822
|
const limit = stickyLimitForPool(options.config);
|
|
793
|
-
const picked = pickRoundRobinAccount(POOL_KEY_ANTHROPIC, getEligibleAnthropicAccounts(), limit);
|
|
823
|
+
const picked = pickRoundRobinAccount(POOL_KEY_ANTHROPIC, routeCandidates(getEligibleAnthropicAccounts(), options.routeDecision ?? null), limit);
|
|
794
824
|
if (picked !== accountId) seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId);
|
|
795
825
|
notePoolRotationSuccess(POOL_KEY_ANTHROPIC, accountId, limit);
|
|
796
826
|
}
|
|
797
|
-
|
|
827
|
+
const key = normalizeAffinityComponent(options.sessionKey);
|
|
828
|
+
const bound = key ? sessionAffinity.get(key) : undefined;
|
|
829
|
+
const eligibleAtCommit = options.routeDecision && bound ? getEligibleAnthropicAccounts() : [];
|
|
830
|
+
const preserveExcludedAffinity = options.routeDecision && bound && bound.accountId !== accountId
|
|
831
|
+
&& Date.now() - bound.lastUsedAt <= AFFINITY_IDLE_TTL_MS
|
|
832
|
+
&& eligibleAtCommit.includes(bound.accountId)
|
|
833
|
+
&& !routeCandidates(eligibleAtCommit, options.routeDecision).includes(bound.accountId);
|
|
834
|
+
if (!preserveExcludedAffinity) bindAnthropicSessionAffinity(options.sessionKey, accountId);
|
|
798
835
|
}
|
|
799
836
|
if (manualPreference === undefined || (manualPreference?.accountId === expectedSelection.accountId
|
|
800
837
|
&& manualPreference.revision === expectedSelection.revision)) manualPreference = null;
|
package/src/oauth/devin.ts
CHANGED
|
@@ -19,7 +19,8 @@ import { DEFAULT_REGION, type WindsurfRegion } from "./devin/types";
|
|
|
19
19
|
import { registerUser } from "./devin/register-user";
|
|
20
20
|
import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiBaseUrl, validateDevinApiBaseUrl } from "./devin/api-base";
|
|
21
21
|
import { readDevinCliCredentialOutcome } from "./devin/cli-import";
|
|
22
|
-
import {
|
|
22
|
+
import { CloudAuthError, mintUserJwt } from "../adapters/devin/cloud-direct/auth";
|
|
23
|
+
import { getCredential, listAccounts, type AuthStore } from "./store";
|
|
23
24
|
import { DEPRECATED_OAUTH_PROVIDER_ALIASES } from "./index";
|
|
24
25
|
|
|
25
26
|
export { DEVIN_DEFAULT_API_SERVER } from "./devin/api-base";
|
|
@@ -263,14 +264,143 @@ export async function loginDevin(
|
|
|
263
264
|
return loginDevinBrowser(ctrl, DEFAULT_REGION);
|
|
264
265
|
}
|
|
265
266
|
|
|
267
|
+
/**
|
|
268
|
+
* A CLI-imported account follows the CLI: after `devin auth login` rewrites the
|
|
269
|
+
* credential file, the copy stored at import time is stale while the file holds
|
|
270
|
+
* a live key. The session JWT carries no expiry, so the upstream 401 is the only
|
|
271
|
+
* signal, and this re-read runs only on that forced refresh.
|
|
272
|
+
*
|
|
273
|
+
* Adoption fails closed on identity. The session token carries only a
|
|
274
|
+
* session_id, so the file key's account is established by minting a user_jwt
|
|
275
|
+
* with it (GetUserJwt answers with auth_uid and email). A key Cognition refuses
|
|
276
|
+
* (401/403) or whose token lacks auth_uid is refused; a mint that fails for any
|
|
277
|
+
* other reason throws a non-terminal error, so the account is not flagged and
|
|
278
|
+
* the next 401 retries. The minted identity must then
|
|
279
|
+
* not contradict the slot's recorded accountId or email, and no other stored
|
|
280
|
+
* account may own the key or that identity.
|
|
281
|
+
*
|
|
282
|
+
* A CLI-imported slot has no bound identity. It cannot adopt a changed key;
|
|
283
|
+
* explicit `ocx login devin` is required after its first rotation.
|
|
284
|
+
*/
|
|
285
|
+
/** An identity probe must not hold the per-account refresh lock for the mint's full 30s. */
|
|
286
|
+
const DEVIN_IDENTITY_MINT_TIMEOUT_MS = 5_000;
|
|
287
|
+
|
|
288
|
+
/** Neither a live key nor a dead one: the refresh must fail without flagging the account. */
|
|
289
|
+
class DevinIdentityProbeUnavailableError extends Error {
|
|
290
|
+
constructor() {
|
|
291
|
+
super("Could not confirm the Devin CLI session identity right now; retry shortly.");
|
|
292
|
+
this.name = "DevinIdentityProbeUnavailableError";
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const normalizedEmail = (value: string | undefined): string | undefined =>
|
|
297
|
+
value?.trim().toLowerCase() || undefined;
|
|
298
|
+
|
|
299
|
+
const devinMintedIdentities = new WeakMap<OAuthCredentials, ReadonlySet<string>>();
|
|
300
|
+
|
|
301
|
+
/** Recheck the minted identity and key against the locked, freshly read store. */
|
|
302
|
+
export function assertDevinCliAdoptionOwnership(
|
|
303
|
+
store: AuthStore,
|
|
304
|
+
provider: string,
|
|
305
|
+
accountId: string,
|
|
306
|
+
credential: OAuthCredentials,
|
|
307
|
+
): void {
|
|
308
|
+
const mintedIds = devinMintedIdentities.get(credential);
|
|
309
|
+
if (!mintedIds) return;
|
|
310
|
+
const email = normalizedEmail(credential.email);
|
|
311
|
+
for (const slot of [provider, ...devinAliasCredentialSlots(provider)]) {
|
|
312
|
+
for (const row of store[slot]?.accounts ?? []) {
|
|
313
|
+
// A legacy alias can still hold the same account id during rekey.
|
|
314
|
+
if (row.id === accountId) continue;
|
|
315
|
+
if (row.credential.access === credential.access
|
|
316
|
+
|| (row.credential.accountId !== undefined && mintedIds.has(row.credential.accountId))
|
|
317
|
+
|| (email !== undefined && normalizedEmail(row.credential.email) === email)) {
|
|
318
|
+
throw new DevinIdentityProbeUnavailableError();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function rereadDevinCliCredential(
|
|
325
|
+
stored: OAuthCredentials,
|
|
326
|
+
signal: AbortSignal | undefined,
|
|
327
|
+
currentAccountId: string | undefined,
|
|
328
|
+
): Promise<OAuthCredentials | undefined> {
|
|
329
|
+
const outcome = readDevinCliCredentialOutcome();
|
|
330
|
+
// A file that exists but cannot be read or parsed may be mid-write by `devin auth login`
|
|
331
|
+
// or briefly locked; like a failed identity mint, that says nothing about the key.
|
|
332
|
+
if (outcome.kind === "unreadable" || outcome.kind === "incomplete") throw new DevinIdentityProbeUnavailableError();
|
|
333
|
+
if (outcome.kind !== "ok" || outcome.file.apiKey === stored.access) return undefined;
|
|
334
|
+
// Without a stored identity, the CLI file may now belong to another user.
|
|
335
|
+
if (!stored.accountId && !normalizedEmail(stored.email)?.includes("@")) return undefined;
|
|
336
|
+
const apiBaseUrl = validateDevinApiBaseUrl(outcome.file.apiServerUrl);
|
|
337
|
+
if (apiBaseUrl === undefined) return undefined;
|
|
338
|
+
// A detached alias rekey can already hold this account's new key. Only a
|
|
339
|
+
// different account's key is a conflict; the locked write checks again.
|
|
340
|
+
for (const slot of ["devin", ...devinAliasCredentialSlots("devin")]) {
|
|
341
|
+
if (listAccounts(slot).some(({ id, credential }) =>
|
|
342
|
+
id !== currentAccountId && credential.access === outcome.file.apiKey)) return undefined;
|
|
343
|
+
}
|
|
344
|
+
let minted: Record<string, unknown> | undefined;
|
|
345
|
+
try {
|
|
346
|
+
const timeout = AbortSignal.timeout(DEVIN_IDENTITY_MINT_TIMEOUT_MS);
|
|
347
|
+
const probeSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
348
|
+
minted = decodeJwtPayload((await mintUserJwt(outcome.file.apiKey, apiBaseUrl, probeSignal)).jwt);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
// Only Cognition refusing the key is evidence the file holds no live session. A timeout,
|
|
351
|
+
// DNS failure, 5xx or 429 says nothing about the key; flagging the account on one would
|
|
352
|
+
// strand it, because a needsReauth slot is never refreshed again.
|
|
353
|
+
if (error instanceof CloudAuthError && (error.status === 401 || error.status === 403)) return undefined;
|
|
354
|
+
throw new DevinIdentityProbeUnavailableError();
|
|
355
|
+
}
|
|
356
|
+
const authUid = typeof minted?.auth_uid === "string" && minted.auth_uid ? minted.auth_uid : undefined;
|
|
357
|
+
if (authUid === undefined) return undefined;
|
|
358
|
+
// identityFromApiKey records `sub ?? auth_uid`, so a stored id may be either claim.
|
|
359
|
+
const mintedIds = new Set([authUid, ...(typeof minted?.sub === "string" && minted.sub ? [minted.sub] : [])]);
|
|
360
|
+
const rawEmail = typeof minted?.email === "string" ? minted.email.trim() : "";
|
|
361
|
+
const email = rawEmail || undefined;
|
|
362
|
+
if (stored.accountId && !mintedIds.has(stored.accountId)) return undefined;
|
|
363
|
+
const storedEmail = normalizedEmail(stored.email);
|
|
364
|
+
if (storedEmail?.includes("@") && storedEmail !== normalizedEmail(email)) return undefined;
|
|
365
|
+
if (devinIdentityOwnedElsewhere(stored, currentAccountId, mintedIds, normalizedEmail(email))) return undefined;
|
|
366
|
+
const adopted = { ...credentialsFromApiKey(outcome.file.apiKey, apiBaseUrl, "local-cli"), accountId: authUid, ...(email ? { email } : {}) };
|
|
367
|
+
devinMintedIdentities.set(adopted, mintedIds);
|
|
368
|
+
return adopted;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Every stored Devin row except the one being refreshed, across the alias-linked slots
|
|
373
|
+
* (the active row `getCredential` reads is one of these). Rows are skipped by id; only a
|
|
374
|
+
* caller that cannot name the row falls back to matching its key.
|
|
375
|
+
*/
|
|
376
|
+
function devinIdentityOwnedElsewhere(
|
|
377
|
+
stored: OAuthCredentials,
|
|
378
|
+
currentAccountId: string | undefined,
|
|
379
|
+
mintedIds: ReadonlySet<string>,
|
|
380
|
+
email: string | undefined,
|
|
381
|
+
): boolean {
|
|
382
|
+
for (const slot of ["devin", ...devinAliasCredentialSlots("devin")]) {
|
|
383
|
+
for (const { id, credential } of listAccounts(slot)) {
|
|
384
|
+
if (currentAccountId !== undefined ? id === currentAccountId : credential.access === stored.access) continue;
|
|
385
|
+
if (credential.accountId !== undefined && mintedIds.has(credential.accountId)) return true;
|
|
386
|
+
if (email && normalizedEmail(credential.email) === email) return true;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
|
|
266
392
|
export async function refreshDevinToken(
|
|
267
393
|
_refreshToken: string,
|
|
268
|
-
|
|
269
|
-
|
|
394
|
+
signal?: AbortSignal,
|
|
395
|
+
credential?: OAuthCredentials,
|
|
396
|
+
accountId?: string,
|
|
270
397
|
): Promise<OAuthCredentials> {
|
|
398
|
+
const reread = credential?.source === "local-cli"
|
|
399
|
+
? await rereadDevinCliCredential(credential, signal, accountId) : undefined;
|
|
400
|
+
if (reread) return reread;
|
|
271
401
|
// Cognition has no refresh endpoint. Extending the stored expiry here is what
|
|
272
402
|
// the carried implementation did, and it makes a revoked key look valid
|
|
273
|
-
// forever. Throwing
|
|
274
|
-
//
|
|
403
|
+
// forever. Throwing on the upstream-401 forced refresh is what marks the
|
|
404
|
+
// account needsReauth.
|
|
275
405
|
throw new Error("invalid_grant: Devin API keys do not refresh. Run ocx login devin again.");
|
|
276
406
|
}
|