@bitkyc08/opencodex 2.7.30 → 2.7.31
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.ko.md +1 -1
- package/README.md +1 -1
- package/README.ru.md +479 -0
- package/README.zh-CN.md +1 -1
- package/gui/dist/assets/index-BPa0R6EN.js +46 -0
- package/gui/dist/assets/{index-B-UauL1p.css → index-BY7KvJRB.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/discovery.ts +2 -2
- package/src/adapters/google.ts +7 -0
- package/src/codex/auth-api.ts +18 -0
- package/src/codex/catalog.ts +33 -10
- package/src/codex/routing.ts +85 -4
- package/src/combos/index.ts +1 -1
- package/src/combos/request.ts +26 -4
- package/src/combos/types.ts +4 -4
- package/src/lib/upstream-retry.ts +21 -0
- package/src/lib/winsw.ts +7 -1
- package/src/oauth/anthropic.ts +23 -1
- package/src/oauth/index.ts +83 -4
- package/src/oauth/local-token-detect.ts +3 -0
- package/src/oauth/store.ts +32 -0
- package/src/providers/antigravity-models.ts +23 -12
- package/src/providers/registry.ts +38 -16
- package/src/server/index.ts +3 -3
- package/src/server/relay.ts +31 -5
- package/src/server/request-log.ts +12 -0
- package/src/server/responses.ts +111 -27
- package/src/service.ts +12 -3
- package/src/types.ts +2 -2
- package/src/usage/expected-prices.ts +12 -9
- package/gui/dist/assets/index-avcinRsG.js +0 -40
package/src/codex/routing.ts
CHANGED
|
@@ -25,14 +25,31 @@ export type CodexThreadResolution =
|
|
|
25
25
|
const threadAccountMap = new Map<string, ThreadAffinityEntry>();
|
|
26
26
|
type CodexUpstreamHealth = {
|
|
27
27
|
consecutiveFailures: number;
|
|
28
|
+
/** Consecutive healthy terminals observed while recovering from escalation level 2+. */
|
|
29
|
+
consecutiveSuccesses?: number;
|
|
28
30
|
lastFailureStatus?: number;
|
|
29
31
|
lastFailureAt?: number;
|
|
32
|
+
/** Hard cooldown (quota 429). Survives a later 2xx; blocks auth + selection. */
|
|
30
33
|
cooldownUntil?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Soft avoid after connect_error / timeout / transient 5xx. Cleared on 2xx.
|
|
36
|
+
* Blocks pool selection + thread affinity reuse so a sticky session can leave a
|
|
37
|
+
* flaky account without throwing CodexAccountCooldownError (hard-only).
|
|
38
|
+
*/
|
|
39
|
+
softAvoidUntil?: number;
|
|
31
40
|
};
|
|
32
41
|
|
|
33
42
|
const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
|
|
34
43
|
const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
|
|
35
44
|
export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
|
|
45
|
+
/** How long a transient failure keeps the account out of pool selection. */
|
|
46
|
+
export const CODEX_TRANSIENT_SOFT_AVOID_MS = 30_000;
|
|
47
|
+
const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [
|
|
48
|
+
CODEX_TRANSIENT_SOFT_AVOID_MS,
|
|
49
|
+
2 * 60_000,
|
|
50
|
+
10 * 60_000,
|
|
51
|
+
30 * 60_000,
|
|
52
|
+
] as const;
|
|
36
53
|
export const CODEX_THREAD_AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
|
|
37
54
|
export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048;
|
|
38
55
|
// Min interval between quota threshold re-evaluations for a single bound thread.
|
|
@@ -47,6 +64,8 @@ export type CodexUpstreamOutcomeMeta = {
|
|
|
47
64
|
retryAfter?: string | null;
|
|
48
65
|
resetAt?: unknown | unknown[];
|
|
49
66
|
now?: number;
|
|
67
|
+
/** When set, clears affinity for this thread immediately on transient failure. */
|
|
68
|
+
threadId?: string | null;
|
|
50
69
|
};
|
|
51
70
|
|
|
52
71
|
function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean {
|
|
@@ -162,8 +181,21 @@ export function isCodexAccountInCooldown(accountId: string, now = Date.now()): b
|
|
|
162
181
|
return getCodexAccountCooldownUntil(accountId, now) !== null;
|
|
163
182
|
}
|
|
164
183
|
|
|
184
|
+
export function getCodexAccountSoftAvoidUntil(accountId: string, now = Date.now()): number | null {
|
|
185
|
+
const softAvoidUntil = upstreamHealth.get(accountId)?.softAvoidUntil;
|
|
186
|
+
return typeof softAvoidUntil === "number" && Number.isFinite(softAvoidUntil) && softAvoidUntil > now
|
|
187
|
+
? softAvoidUntil
|
|
188
|
+
: null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function isCodexAccountSoftAvoided(accountId: string, now = Date.now()): boolean {
|
|
192
|
+
return getCodexAccountSoftAvoidUntil(accountId, now) !== null;
|
|
193
|
+
}
|
|
194
|
+
|
|
165
195
|
function isCodexAccountSelectable(config: OcxConfig, accountId: string, now: number): boolean {
|
|
166
|
-
return !isCodexAccountInCooldown(accountId, now)
|
|
196
|
+
return !isCodexAccountInCooldown(accountId, now)
|
|
197
|
+
&& !isCodexAccountSoftAvoided(accountId, now)
|
|
198
|
+
&& isCodexAccountUsable(config, accountId);
|
|
167
199
|
}
|
|
168
200
|
|
|
169
201
|
function isThreadAffinityExpired(entry: ThreadAffinityEntry, now: number): boolean {
|
|
@@ -215,6 +247,7 @@ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Da
|
|
|
215
247
|
const ids = (config.codexAccounts ?? [])
|
|
216
248
|
.filter(account => !account.isMain && account.id !== excludeId && !isAccountNeedsReauth(account.id))
|
|
217
249
|
.filter(account => !isCodexAccountInCooldown(account.id, now))
|
|
250
|
+
.filter(account => !isCodexAccountSoftAvoided(account.id, now))
|
|
218
251
|
.filter(account => isCodexAccountUsable(config, account.id))
|
|
219
252
|
.map(account => account.id);
|
|
220
253
|
// The main Codex account is not stored in config.codexAccounts; include it as a
|
|
@@ -223,6 +256,7 @@ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Da
|
|
|
223
256
|
excludeId !== MAIN_CODEX_ACCOUNT_ID
|
|
224
257
|
&& !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
|
|
225
258
|
&& !isCodexAccountInCooldown(MAIN_CODEX_ACCOUNT_ID, now)
|
|
259
|
+
&& !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
|
|
226
260
|
&& isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID)
|
|
227
261
|
) {
|
|
228
262
|
ids.unshift(MAIN_CODEX_ACCOUNT_ID);
|
|
@@ -352,6 +386,9 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
352
386
|
if (
|
|
353
387
|
isThreadAffinityGenerationLive(entry)
|
|
354
388
|
&& isCodexAccountSelectable(config, entry.accountId, now)
|
|
389
|
+
// Affined threads must leave a failing account once the streak trips failover
|
|
390
|
+
// (soft-avoid covers the first-hit case; this catches post-avoid residual streaks).
|
|
391
|
+
&& !shouldFailover(config, entry.accountId, now)
|
|
355
392
|
) {
|
|
356
393
|
entry.lastUsedAt = now;
|
|
357
394
|
// Periodic quota re-eval: a long-lived bound thread must still switch when
|
|
@@ -420,7 +457,22 @@ export function recordCodexUpstreamOutcome(
|
|
|
420
457
|
const now = meta.now ?? Date.now();
|
|
421
458
|
const outcomeClass = classifyCodexUpstreamOutcome(outcome);
|
|
422
459
|
if (outcomeClass === "success") {
|
|
460
|
+
const current = upstreamHealth.get(accountId);
|
|
423
461
|
const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
|
|
462
|
+
const failoverEnabled = (config.upstreamFailoverThreshold ?? 3) > 0;
|
|
463
|
+
if (failoverEnabled && current && current.consecutiveFailures >= 2) {
|
|
464
|
+
const consecutiveSuccesses = (current.consecutiveSuccesses ?? 0) + 1;
|
|
465
|
+
if (consecutiveSuccesses < 2) {
|
|
466
|
+
upstreamHealth.set(accountId, {
|
|
467
|
+
...current,
|
|
468
|
+
consecutiveSuccesses,
|
|
469
|
+
...(cooldownUntil ? { cooldownUntil } : {}),
|
|
470
|
+
});
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// Level 1 clears immediately; escalated accounts need two consecutive healthy terminals.
|
|
475
|
+
// Hard quota cooldown intentionally survives either recovery path.
|
|
424
476
|
if (cooldownUntil) upstreamHealth.set(accountId, { consecutiveFailures: 0, cooldownUntil });
|
|
425
477
|
else upstreamHealth.delete(accountId);
|
|
426
478
|
return;
|
|
@@ -454,15 +506,44 @@ export function recordCodexUpstreamOutcome(
|
|
|
454
506
|
return;
|
|
455
507
|
}
|
|
456
508
|
|
|
509
|
+
// transient (connect_error / timeout / 5xx)
|
|
457
510
|
const current = upstreamHealth.get(accountId);
|
|
458
511
|
const stale = current?.lastFailureAt ? now - current.lastFailureAt > CODEX_FAILURE_WINDOW_MS : false;
|
|
459
|
-
const
|
|
512
|
+
const hardCooldownUntil = getCodexAccountCooldownUntil(accountId, now) ?? undefined;
|
|
513
|
+
// Soft avoid + affinity clears are part of failover. When threshold is 0, leave
|
|
514
|
+
// sticky sessions alone (same as shouldFailover / applyFailureFailover no-ops).
|
|
515
|
+
const failoverEnabled = (config.upstreamFailoverThreshold ?? 3) > 0;
|
|
516
|
+
const consecutiveFailures = stale ? 1 : (current?.consecutiveFailures ?? 0) + 1;
|
|
517
|
+
const escalationMs = CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS[
|
|
518
|
+
Math.min(consecutiveFailures, CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS.length) - 1
|
|
519
|
+
]!;
|
|
520
|
+
const softAvoidUntil = failoverEnabled
|
|
521
|
+
? Math.max(
|
|
522
|
+
getCodexAccountSoftAvoidUntil(accountId, now) ?? 0,
|
|
523
|
+
now + escalationMs,
|
|
524
|
+
)
|
|
525
|
+
: undefined;
|
|
460
526
|
upstreamHealth.set(accountId, {
|
|
461
|
-
consecutiveFailures
|
|
527
|
+
consecutiveFailures,
|
|
462
528
|
lastFailureStatus,
|
|
463
529
|
lastFailureAt: now,
|
|
464
|
-
...(
|
|
530
|
+
...(hardCooldownUntil ? { cooldownUntil: hardCooldownUntil } : {}),
|
|
531
|
+
...(softAvoidUntil !== undefined ? { softAvoidUntil } : {}),
|
|
465
532
|
});
|
|
533
|
+
// Drop this thread's pin immediately so the next continue can rebind without
|
|
534
|
+
// waiting for the soft-avoid selectable check. Guard: only delete when the
|
|
535
|
+
// thread is still pinned to the FAILING account — a late failure from account A
|
|
536
|
+
// must not delete a newer healthy binding to account B (race: T→A, A fails,
|
|
537
|
+
// T→B, late A failure must not delete B's mapping).
|
|
538
|
+
if (failoverEnabled && meta.threadId) {
|
|
539
|
+
const bound = threadAccountMap.get(meta.threadId);
|
|
540
|
+
if (bound?.accountId === accountId) threadAccountMap.delete(meta.threadId);
|
|
541
|
+
}
|
|
542
|
+
// Once the account is past the failover streak, clear every thread still pinned
|
|
543
|
+
// to it — matching 429 affinity behavior so "continue" cannot stay on a bad peer.
|
|
544
|
+
if (shouldFailover(config, accountId, now)) {
|
|
545
|
+
clearThreadAccountMapForAccount(accountId);
|
|
546
|
+
}
|
|
466
547
|
if (config.activeCodexAccountId === accountId) applyFailureFailover(config, accountId, now);
|
|
467
548
|
}
|
|
468
549
|
|
package/src/combos/index.ts
CHANGED
package/src/combos/request.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { OcxComboDefaultEffort, OcxComboTarget } from "../types";
|
|
2
2
|
import { parseComboModelId } from "./types";
|
|
3
3
|
|
|
4
|
+
const warnedUnsupportedDefaults = new Set<string>();
|
|
5
|
+
|
|
6
|
+
export function resetComboEffortWarningStateForTests(): void {
|
|
7
|
+
warnedUnsupportedDefaults.clear();
|
|
8
|
+
}
|
|
9
|
+
|
|
4
10
|
export function comboIdFromRawBody(body: unknown): string | null {
|
|
5
11
|
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
6
12
|
const model = (body as { model?: unknown }).model;
|
|
@@ -12,19 +18,35 @@ export function concreteComboRequestBody(
|
|
|
12
18
|
body: unknown,
|
|
13
19
|
target: Pick<OcxComboTarget, "provider" | "model">,
|
|
14
20
|
defaultEffort: OcxComboDefaultEffort | null,
|
|
21
|
+
targetReasoningEfforts: readonly string[] | undefined,
|
|
15
22
|
): Record<string, unknown> {
|
|
16
23
|
const clone = structuredClone(body) as Record<string, unknown>;
|
|
17
24
|
clone.model = `${target.provider}/${target.model}`;
|
|
18
25
|
if (!defaultEffort) return clone;
|
|
19
26
|
const reasoning = clone.reasoning;
|
|
20
|
-
|
|
21
|
-
clone.reasoning = { effort: defaultEffort };
|
|
22
|
-
} else if (
|
|
27
|
+
const needsDefault = reasoning === undefined || (
|
|
23
28
|
reasoning
|
|
24
29
|
&& typeof reasoning === "object"
|
|
25
30
|
&& !Array.isArray(reasoning)
|
|
26
31
|
&& !Object.prototype.hasOwnProperty.call(reasoning, "effort")
|
|
27
|
-
)
|
|
32
|
+
);
|
|
33
|
+
if (!needsDefault) return clone;
|
|
34
|
+
if (!targetReasoningEfforts?.includes(defaultEffort)) {
|
|
35
|
+
const key = `${target.provider}/${target.model}:${defaultEffort}`;
|
|
36
|
+
if (!warnedUnsupportedDefaults.has(key)) {
|
|
37
|
+
warnedUnsupportedDefaults.add(key);
|
|
38
|
+
console.debug("[opencodex] combo default effort omitted", {
|
|
39
|
+
provider: target.provider,
|
|
40
|
+
model: target.model,
|
|
41
|
+
requestedEffort: defaultEffort,
|
|
42
|
+
capability: targetReasoningEfforts === undefined ? "unknown" : "unsupported",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return clone;
|
|
46
|
+
}
|
|
47
|
+
if (reasoning === undefined) {
|
|
48
|
+
clone.reasoning = { effort: defaultEffort };
|
|
49
|
+
} else {
|
|
28
50
|
clone.reasoning = { ...(reasoning as Record<string, unknown>), effort: defaultEffort };
|
|
29
51
|
}
|
|
30
52
|
return clone;
|
package/src/combos/types.ts
CHANGED
|
@@ -8,7 +8,6 @@ import type {
|
|
|
8
8
|
} from "../types";
|
|
9
9
|
|
|
10
10
|
export const COMBO_NAMESPACE = "combo";
|
|
11
|
-
export const COMBO_DEFAULT_EFFORT: OcxComboDefaultEffort = "medium";
|
|
12
11
|
const COMBO_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
13
12
|
|
|
14
13
|
export interface ComboValidationIssue {
|
|
@@ -19,7 +18,7 @@ export interface ComboValidationIssue {
|
|
|
19
18
|
export interface NormalizedComboConfig {
|
|
20
19
|
strategy: OcxComboStrategy;
|
|
21
20
|
stickyLimit: number;
|
|
22
|
-
defaultEffort: OcxComboDefaultEffort;
|
|
21
|
+
defaultEffort: OcxComboDefaultEffort | null;
|
|
23
22
|
targets: Array<Required<OcxComboTarget>>;
|
|
24
23
|
}
|
|
25
24
|
|
|
@@ -81,6 +80,7 @@ export function comboConfigIssues(
|
|
|
81
80
|
issues.push({ path: ["stickyLimit"], message: "stickyLimit must be an integer from 1 to 100" });
|
|
82
81
|
}
|
|
83
82
|
if (body.defaultEffort !== undefined
|
|
83
|
+
&& body.defaultEffort !== null
|
|
84
84
|
&& (typeof body.defaultEffort !== "string" || !isCodexReasoningEffort(body.defaultEffort))) {
|
|
85
85
|
issues.push({
|
|
86
86
|
path: ["defaultEffort"],
|
|
@@ -164,7 +164,7 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig
|
|
|
164
164
|
return {
|
|
165
165
|
strategy: raw.strategy ?? "failover",
|
|
166
166
|
stickyLimit: raw.stickyLimit ?? 1,
|
|
167
|
-
defaultEffort: raw.defaultEffort ??
|
|
167
|
+
defaultEffort: raw.defaultEffort ?? null,
|
|
168
168
|
targets: raw.targets.map(target => ({
|
|
169
169
|
provider: target.provider.trim(),
|
|
170
170
|
model: target.model.trim(),
|
|
@@ -179,7 +179,7 @@ export function comboDefaultEffort(
|
|
|
179
179
|
): OcxComboDefaultEffort | null {
|
|
180
180
|
const combos = config.combos;
|
|
181
181
|
if (!combos || !Object.hasOwn(combos, id)) return null;
|
|
182
|
-
const value: unknown = combos[id]!.defaultEffort ??
|
|
182
|
+
const value: unknown = combos[id]!.defaultEffort ?? null;
|
|
183
183
|
return typeof value === "string" && isCodexReasoningEffort(value)
|
|
184
184
|
? value as OcxComboDefaultEffort
|
|
185
185
|
: null;
|
|
@@ -148,6 +148,27 @@ export interface TransientRetryOptions extends ResetRetryOptions {
|
|
|
148
148
|
export type UpstreamSendRecovery = "connection-reset" | "transient-5xx";
|
|
149
149
|
type ReplayableFetch = (recovery?: UpstreamSendRecovery) => Promise<Response>;
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Opt out of Bun's keep-alive pool after a connection-reset retry.
|
|
153
|
+
*
|
|
154
|
+
* Prefer the Bun fetch extension `keepalive: false` (transport-level) over
|
|
155
|
+
* relying on the hop-by-hop `Connection: close` header alone — Bun has ignored
|
|
156
|
+
* that header in past releases (oven-sh/bun#20492), so a header-only retry can
|
|
157
|
+
* still reuse the same half-closed pooled socket. Still set Connection: close
|
|
158
|
+
* as a belt-and-suspenders signal for intermediaries that honor it.
|
|
159
|
+
*/
|
|
160
|
+
export function applyUpstreamRecoveryInit<T extends RequestInit>(
|
|
161
|
+
init: T,
|
|
162
|
+
recovery?: UpstreamSendRecovery,
|
|
163
|
+
): T & { headers: Headers } {
|
|
164
|
+
const headers = new Headers(init.headers);
|
|
165
|
+
if (recovery !== "connection-reset") {
|
|
166
|
+
return { ...init, headers };
|
|
167
|
+
}
|
|
168
|
+
headers.set("connection", "close");
|
|
169
|
+
return { ...init, headers, keepalive: false };
|
|
170
|
+
}
|
|
171
|
+
|
|
151
172
|
/**
|
|
152
173
|
* Run `doFetch`, retrying only connection-reset-shaped rejections (see
|
|
153
174
|
* isConnectionResetError) with jittered backoff. The caller's thunk must be replay-safe
|
package/src/lib/winsw.ts
CHANGED
|
@@ -223,7 +223,13 @@ export function probeScmRegistration(run: () => string = queryScmForService): bo
|
|
|
223
223
|
const text = [e.stderr, e.stdout, e.message]
|
|
224
224
|
.map(v => (typeof v === "string" ? v : ""))
|
|
225
225
|
.join("\n");
|
|
226
|
-
|
|
226
|
+
// ERROR_SERVICE_DOES_NOT_EXIST: the numeric identifier 1060 is locale-invariant
|
|
227
|
+
// (FALHA 1060 pt-BR, localized ko output, English FAILED 1060). The query is a
|
|
228
|
+
// fixed `sc.exe query <service>` so a standalone 1060 in its output is proof of
|
|
229
|
+
// absence. Bun may deliver e.status as 36 (1060 & 0xff) — status 36 ALONE is
|
|
230
|
+
// NOT accepted (collides with any status ≡ 36 mod 256); the textual 1060 is
|
|
231
|
+
// required corroboration and covers those hosts.
|
|
232
|
+
if (e.status === 1060 || /\b1060\b/.test(text)) return false;
|
|
227
233
|
return "error";
|
|
228
234
|
}
|
|
229
235
|
}
|
package/src/oauth/anthropic.ts
CHANGED
|
@@ -34,6 +34,17 @@ interface AnthropicTokenResponse {
|
|
|
34
34
|
account?: { uuid?: string; email_address?: string };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export class AnthropicTokenError extends Error {
|
|
38
|
+
constructor(
|
|
39
|
+
message: string,
|
|
40
|
+
readonly httpStatus: number | undefined,
|
|
41
|
+
readonly oauthError: string | undefined,
|
|
42
|
+
) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "AnthropicTokenError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
async function postJson(url: string, body: Record<string, string | number>): Promise<string> {
|
|
38
49
|
const response = await fetch(url, {
|
|
39
50
|
method: "POST",
|
|
@@ -43,7 +54,18 @@ async function postJson(url: string, body: Record<string, string | number>): Pro
|
|
|
43
54
|
});
|
|
44
55
|
const responseBody = await response.text();
|
|
45
56
|
if (!response.ok) {
|
|
46
|
-
|
|
57
|
+
let oauthError: string | undefined;
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(responseBody) as { error?: unknown };
|
|
60
|
+
if (typeof parsed.error === "string") oauthError = parsed.error;
|
|
61
|
+
} catch {
|
|
62
|
+
// Best-effort only: malformed error bodies remain structured by HTTP status.
|
|
63
|
+
}
|
|
64
|
+
throw new AnthropicTokenError(
|
|
65
|
+
`Anthropic OAuth HTTP ${response.status}: ${responseBody}`,
|
|
66
|
+
response.status,
|
|
67
|
+
oauthError,
|
|
68
|
+
);
|
|
47
69
|
}
|
|
48
70
|
return responseBody;
|
|
49
71
|
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { parseCallbackInput } from "./callback-server";
|
|
|
3
3
|
import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
|
|
4
4
|
import { loadConfig, resolveEnvValue, saveConfig } from "../config";
|
|
5
5
|
import { maskEmail } from "../lib/privacy";
|
|
6
|
-
import { getAccountCredential, getAccountSet, saveAccountCredential, saveCredential, markAccountNeedsReauth, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration } from "./store";
|
|
6
|
+
import { getAccountCredential, getAccountSet, saveAccountCredential, saveCredential, markAccountNeedsReauth, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store";
|
|
7
7
|
import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai";
|
|
8
|
-
import { ANTHROPIC_OAUTH_BETA, loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
|
8
|
+
import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
|
9
9
|
import { loginKimi, refreshKimiToken } from "./kimi";
|
|
10
10
|
import { loginKiro, readKiroCliSqlite, refreshKiroToken } from "./kiro";
|
|
11
11
|
import { loginChatGPT, refreshChatGPTToken } from "./chatgpt";
|
|
@@ -15,7 +15,7 @@ import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUr
|
|
|
15
15
|
import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
|
|
16
16
|
import { effectiveGoogleMode } from "../providers/registry";
|
|
17
17
|
import { resolveProviderTransport } from "../providers/xai-transport";
|
|
18
|
-
import { detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
|
|
18
|
+
import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
|
|
19
19
|
|
|
20
20
|
const REFRESH_SKEW_MS = 60_000;
|
|
21
21
|
export interface OAuthAccessSnapshot {
|
|
@@ -29,6 +29,7 @@ const tokenRefreshes = new Map<string, Promise<OAuthAccessSnapshot>>();
|
|
|
29
29
|
const XAI_PERMANENT_FAILURE_TTL_MS=30_000;
|
|
30
30
|
const permanentRefreshFailures=new Map<string,number>();
|
|
31
31
|
interface XaiRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void> }
|
|
32
|
+
interface AnthropicRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void> }
|
|
32
33
|
function verdictKey(p:string,a:string,c:OAuthCredentials){return `${p}\0${a}\0${credentialGeneration(c)}`;}
|
|
33
34
|
function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=verdictKey(p,a,c),u=permanentRefreshFailures.get(k);if(u===undefined)return false;if(u<=now()){permanentRefreshFailures.delete(k);return false;}return true;}
|
|
34
35
|
|
|
@@ -257,7 +258,11 @@ function isTerminalRefreshError(err: unknown): boolean {
|
|
|
257
258
|
|| msg.includes("access_denied")
|
|
258
259
|
|| msg.includes("expired_token");
|
|
259
260
|
}
|
|
260
|
-
function terminal(error:unknown):boolean{
|
|
261
|
+
function terminal(error:unknown):boolean{
|
|
262
|
+
if(error instanceof XaiTokenRequestError)return ["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??"");
|
|
263
|
+
if(error instanceof AnthropicTokenError)return (error.httpStatus===400||error.httpStatus===401)&&["invalid_grant","refresh_token_reused","revoked","revoked_token","refresh_token_revoked"].includes(error.oauthError??"");
|
|
264
|
+
return isTerminalRefreshError(error);
|
|
265
|
+
}
|
|
261
266
|
function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OAuthCredentials{if(stored.source!=="local-cli")return stored;const disk=detectGrokCliToken();if(!disk)return stored;const allowed=isSameGrokIdentity(stored,disk)||(active&&!hasComparableGrokIdentity(stored,disk));return allowed&&shouldAdoptGrokGeneration(stored,disk,now(),REFRESH_SKEW_MS)?disk:stored;}
|
|
262
267
|
function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCredentials {
|
|
263
268
|
return {
|
|
@@ -271,6 +276,79 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede
|
|
|
271
276
|
}
|
|
272
277
|
export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise<string>{const now=deps.now??Date.now;const guard=await(deps.intentLock??createOAuthRefreshIntentLock(provider,accountId)).acquire();try{const stored=getAccountCredential(provider,accountId);if(!stored)throw new OAuthLoginRequiredError(provider);const active=getAccountSet(provider)?.activeAccountId===accountId,candidate=authoritative(stored,active,now);if(credentialGeneration(candidate)!==credentialGeneration(callerCredential)&&candidate.expires>now()+REFRESH_SKEW_MS){if(credentialGeneration(candidate)!==credentialGeneration(stored)){const o=await mergeAccountCredential(provider,accountId,candidate,{expectedGeneration:credentialGeneration(stored),afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}}return candidate.access;}if(cached(provider,accountId,candidate,now))throw new OAuthLoginRequiredError(provider);const generation=credentialGeneration(candidate);try{const fresh=merged(await def.refresh(candidate.refresh),candidate);const o=await mergeAccountCredential(provider,accountId,fresh,{expectedGeneration:generation,afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));if(candidate.source==="local-cli")console.warn(XAI_LOCAL_CLI_DETACH_WARNING);return fresh.access;}catch(error){if(!terminal(error))throw error;permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),now()+XAI_PERMANENT_FAILURE_TTL_MS);await markAccountNeedsReauthIfGeneration(provider,accountId,generation);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}}
|
|
273
278
|
|
|
279
|
+
function newerClaudeCredential(stored: OAuthCredentials, now: number): OAuthCredentials | undefined {
|
|
280
|
+
if (stored.source !== "local-cli") return undefined;
|
|
281
|
+
const disk = detectClaudeCodeToken();
|
|
282
|
+
if (!disk || disk.expires <= now + REFRESH_SKEW_MS) return undefined;
|
|
283
|
+
return credentialGeneration(disk) !== credentialGeneration(stored) ? disk : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function refreshAnthropicAccountWithLock(
|
|
287
|
+
provider: string,
|
|
288
|
+
accountId: string,
|
|
289
|
+
def: OAuthProviderDef,
|
|
290
|
+
callerCredential: OAuthCredentials,
|
|
291
|
+
deps: AnthropicRefreshDeps = {},
|
|
292
|
+
): Promise<string> {
|
|
293
|
+
const now = deps.now ?? Date.now;
|
|
294
|
+
const guard = await (deps.intentLock ?? createOAuthRefreshIntentLock(provider, accountId)).acquire();
|
|
295
|
+
try {
|
|
296
|
+
const stored = getAccountCredential(provider, accountId);
|
|
297
|
+
if (!stored) throw new OAuthLoginRequiredError(provider);
|
|
298
|
+
const account = getAccountSet(provider)?.accounts.find(candidate => candidate.id === accountId);
|
|
299
|
+
const generation = credentialGeneration(stored);
|
|
300
|
+
const pendingIntent = readOAuthRefreshIntent(provider, accountId);
|
|
301
|
+
const disk = newerClaudeCredential(stored, now());
|
|
302
|
+
if (disk) {
|
|
303
|
+
const outcome = await mergeAccountCredential(provider, accountId, disk, {
|
|
304
|
+
expectedGeneration: credentialGeneration(stored),
|
|
305
|
+
afterPrePersistRead: deps.afterPrePersistRead,
|
|
306
|
+
});
|
|
307
|
+
if (outcome.superseded) {
|
|
308
|
+
if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
|
|
309
|
+
if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access;
|
|
310
|
+
throw new OAuthLoginRequiredError(provider);
|
|
311
|
+
}
|
|
312
|
+
if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
|
|
313
|
+
return disk.access;
|
|
314
|
+
}
|
|
315
|
+
if (pendingIntent?.uncertain || pendingIntent?.generation === generation) {
|
|
316
|
+
await markAccountNeedsReauthIfGeneration(provider, accountId, generation);
|
|
317
|
+
throw new OAuthLoginRequiredError(provider);
|
|
318
|
+
}
|
|
319
|
+
if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
|
|
320
|
+
if (account?.needsReauth) {
|
|
321
|
+
throw new OAuthLoginRequiredError(provider);
|
|
322
|
+
}
|
|
323
|
+
if (credentialGeneration(stored) !== credentialGeneration(callerCredential) && stored.expires > now() + REFRESH_SKEW_MS) {
|
|
324
|
+
return stored.access;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
writeOAuthRefreshIntent(provider, accountId, generation, now());
|
|
329
|
+
const fresh = merged(await def.refresh(stored.refresh), stored);
|
|
330
|
+
const outcome = await mergeAccountCredential(provider, accountId, fresh, {
|
|
331
|
+
expectedGeneration: generation,
|
|
332
|
+
afterPrePersistRead: deps.afterPrePersistRead,
|
|
333
|
+
});
|
|
334
|
+
if (outcome.superseded) {
|
|
335
|
+
clearOAuthRefreshIntent(provider, accountId, generation);
|
|
336
|
+
if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access;
|
|
337
|
+
throw new OAuthLoginRequiredError(provider);
|
|
338
|
+
}
|
|
339
|
+
clearOAuthRefreshIntent(provider, accountId, generation);
|
|
340
|
+
return fresh.access;
|
|
341
|
+
} catch (error) {
|
|
342
|
+
if (!terminal(error)) throw error;
|
|
343
|
+
await markAccountNeedsReauthIfGeneration(provider, accountId, generation);
|
|
344
|
+
clearOAuthRefreshIntent(provider, accountId, generation);
|
|
345
|
+
throw new OAuthLoginRequiredError(provider);
|
|
346
|
+
}
|
|
347
|
+
} finally {
|
|
348
|
+
guard.release();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
274
352
|
async function refreshAndPersistAccessToken(
|
|
275
353
|
provider: string,
|
|
276
354
|
accountId: string,
|
|
@@ -288,6 +366,7 @@ async function refreshAndPersistAccessToken(
|
|
|
288
366
|
}
|
|
289
367
|
}
|
|
290
368
|
if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred);
|
|
369
|
+
if (provider === "anthropic") return refreshAnthropicAccountWithLock(provider, accountId, def, cred);
|
|
291
370
|
try {
|
|
292
371
|
const fresh = await def.refresh(cred.refresh);
|
|
293
372
|
const detachedLocalCli = provider === "xai" && cred.source === "local-cli";
|
|
@@ -97,6 +97,9 @@ export function readClaudeCredentialsFile(): string | null {
|
|
|
97
97
|
|
|
98
98
|
/** Keychain first on macOS, then the cross-platform credentials file. */
|
|
99
99
|
function readClaudeSecureStorage(): string | null {
|
|
100
|
+
// An explicit config-dir override identifies a separate Claude installation/profile.
|
|
101
|
+
// Do not let the default macOS Keychain entry shadow that requested credential file.
|
|
102
|
+
if (process.env.CLAUDE_CONFIG_DIR?.trim()) return readClaudeCredentialsFile() ?? readClaudeKeychain();
|
|
100
103
|
return readClaudeKeychain() ?? readClaudeCredentialsFile();
|
|
101
104
|
}
|
|
102
105
|
|
package/src/oauth/store.ts
CHANGED
|
@@ -36,6 +36,38 @@ export function getAuthRefreshIntentLockPath(provider: string, accountId: string
|
|
|
36
36
|
const accountHash = createHash("sha256").update(accountId).digest("hex").slice(0, 24);
|
|
37
37
|
return join(getConfigDir(), `auth.refresh.${safeProvider}.${accountHash}.lock`);
|
|
38
38
|
}
|
|
39
|
+
export function getAuthRefreshIntentPath(provider: string, accountId: string): string {
|
|
40
|
+
return `${getAuthRefreshIntentLockPath(provider, accountId)}.json`;
|
|
41
|
+
}
|
|
42
|
+
export interface OAuthRefreshIntent { version: 1; provider: string; accountId: string; generation: string; createdAt: number; uncertain?: true }
|
|
43
|
+
export function readOAuthRefreshIntent(provider: string, accountId: string): OAuthRefreshIntent | undefined {
|
|
44
|
+
const path = getAuthRefreshIntentPath(provider, accountId);
|
|
45
|
+
try {
|
|
46
|
+
hardenConfigDir();
|
|
47
|
+
hardenExistingSecret(path);
|
|
48
|
+
const value = JSON.parse(readFileSync(path, "utf8")) as Partial<OAuthRefreshIntent>;
|
|
49
|
+
if (value.version !== 1 || value.provider !== provider || value.accountId !== accountId || typeof value.generation !== "string" || typeof value.createdAt !== "number") {
|
|
50
|
+
return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true };
|
|
51
|
+
}
|
|
52
|
+
return value as OAuthRefreshIntent;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (errorCode(error) === "ENOENT") return undefined;
|
|
55
|
+
return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function writeOAuthRefreshIntent(provider: string, accountId: string, generation: string, createdAt = Date.now()): void {
|
|
59
|
+
const dir = getConfigDir();
|
|
60
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
61
|
+
hardenConfigDir();
|
|
62
|
+
const intent: OAuthRefreshIntent = { version: 1, provider, accountId, generation, createdAt };
|
|
63
|
+
atomicWriteFile(getAuthRefreshIntentPath(provider, accountId), `${JSON.stringify(intent)}\n`);
|
|
64
|
+
}
|
|
65
|
+
export function clearOAuthRefreshIntent(provider: string, accountId: string, generation: string): boolean {
|
|
66
|
+
const current = readOAuthRefreshIntent(provider, accountId);
|
|
67
|
+
if (!current || current.generation !== generation) return false;
|
|
68
|
+
try { unlinkSync(getAuthRefreshIntentPath(provider, accountId)); return true; }
|
|
69
|
+
catch (error) { if (errorCode(error) === "ENOENT") return false; throw error; }
|
|
70
|
+
}
|
|
39
71
|
export function credentialGeneration(cred: OAuthCredentials): string {
|
|
40
72
|
return createHash("sha256").update(JSON.stringify([cred.refresh, cred.access, cred.expires])).digest("hex");
|
|
41
73
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// Google Antigravity (Cloud Code Assist) bundled model list.
|
|
2
2
|
//
|
|
3
3
|
// Single source of truth: the Antigravity `:fetchAvailableModels` backend, the same one the `agy`
|
|
4
|
-
// CLI resolves labels against. The ids below
|
|
5
|
-
//
|
|
4
|
+
// CLI resolves labels against. The ids below separate CCA wire ids, visible client aliases, and
|
|
5
|
+
// hidden compatibility aliases for saved selections. The CCA envelope's `model` field must receive the wire id (for example
|
|
6
6
|
// "Gemini 3.1 Pro (High)" => gemini-pro-agent), while the picker can expose label-shaped aliases.
|
|
7
7
|
const ANTIGRAVITY_WIRE_MODELS = [
|
|
8
|
-
"gemini-3.
|
|
9
|
-
"gemini-3-flash-
|
|
10
|
-
"gemini-3.
|
|
8
|
+
"gemini-3.6-flash-low",
|
|
9
|
+
"gemini-3.6-flash-medium",
|
|
10
|
+
"gemini-3.6-flash-high",
|
|
11
11
|
"gemini-3.1-pro-low",
|
|
12
12
|
"gemini-pro-agent",
|
|
13
13
|
"claude-sonnet-4-6",
|
|
@@ -15,23 +15,34 @@ const ANTIGRAVITY_WIRE_MODELS = [
|
|
|
15
15
|
"gpt-oss-120b-medium",
|
|
16
16
|
];
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
"gemini-3.5-flash-mid": "gemini-3.5-flash-low",
|
|
20
|
-
"gemini-3.5-flash-high": "gemini-3-flash-agent",
|
|
18
|
+
const ANTIGRAVITY_VISIBLE_MODEL_ALIASES: Record<string, string> = {
|
|
21
19
|
"gemini-3.1-pro-high": "gemini-pro-agent",
|
|
22
20
|
"gemini-3.1-pro-preview": "gemini-pro-agent",
|
|
23
21
|
};
|
|
24
22
|
|
|
23
|
+
const ANTIGRAVITY_COMPATIBILITY_MODEL_ALIASES: Record<string, string> = {
|
|
24
|
+
"gemini-3.5-flash-extra-low": "gemini-3.6-flash-low",
|
|
25
|
+
"gemini-3.5-flash-low": "gemini-3.6-flash-medium",
|
|
26
|
+
"gemini-3.5-flash-mid": "gemini-3.6-flash-medium",
|
|
27
|
+
"gemini-3.5-flash-high": "gemini-3.6-flash-high",
|
|
28
|
+
"gemini-3-flash-agent": "gemini-3.6-flash-high",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const ANTIGRAVITY_MODEL_ALIASES: Record<string, string> = {
|
|
32
|
+
...ANTIGRAVITY_VISIBLE_MODEL_ALIASES,
|
|
33
|
+
...ANTIGRAVITY_COMPATIBILITY_MODEL_ALIASES,
|
|
34
|
+
};
|
|
35
|
+
|
|
25
36
|
export const ANTIGRAVITY_MODELS = [
|
|
26
37
|
...ANTIGRAVITY_WIRE_MODELS,
|
|
27
|
-
...Object.keys(
|
|
38
|
+
...Object.keys(ANTIGRAVITY_VISIBLE_MODEL_ALIASES),
|
|
28
39
|
];
|
|
29
40
|
|
|
30
41
|
// Context windows from the upstream `:fetchAvailableModels` maxTokens per model.
|
|
31
42
|
const ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
|
32
|
-
"gemini-3.
|
|
33
|
-
"gemini-3-flash-
|
|
34
|
-
"gemini-3.
|
|
43
|
+
"gemini-3.6-flash-low": 1_048_576,
|
|
44
|
+
"gemini-3.6-flash-medium": 1_048_576,
|
|
45
|
+
"gemini-3.6-flash-high": 1_048_576,
|
|
35
46
|
"gemini-3.1-pro-low": 1_048_576,
|
|
36
47
|
"gemini-pro-agent": 1_048_576,
|
|
37
48
|
"claude-sonnet-4-6": 200_000,
|