@bitkyc08/opencodex 2.7.21 → 2.7.22
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-CILVKWmx.css → index-Bk_GgFrh.css} +1 -1
- package/gui/dist/assets/index-BqbMn1sv.js +40 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/claude/outbound.ts +15 -4
- package/src/cli/index.ts +1 -1
- package/src/codex/auth-api.ts +7 -6
- package/src/codex/quota.ts +13 -27
- package/src/codex/routing.ts +1 -2
- package/src/lib/abort.ts +55 -0
- package/src/lib/upstream-retry.ts +62 -0
- package/src/oauth/index.ts +82 -24
- package/src/oauth/local-token-detect.ts +23 -1
- package/src/oauth/store.ts +51 -31
- package/src/oauth/xai.ts +17 -10
- package/src/providers/quota.ts +2 -6
- package/src/providers/xai-transport.ts +107 -60
- package/src/responses/parser.ts +59 -17
- package/src/server/claude-messages.ts +240 -14
- package/src/server/management-api.ts +3 -3
- package/src/server/request-log.ts +13 -1
- package/src/server/responses.ts +67 -18
- package/src/types.ts +13 -0
- package/src/usage/log.ts +11 -0
- package/gui/dist/assets/index-DYIS0tTL.js +0 -40
package/gui/dist/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-BqbMn1sv.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Bk_GgFrh.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
23
23
|
<div id="root"></div>
|
package/package.json
CHANGED
package/src/claude/outbound.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* - message_delta.usage is cumulative; message_start embeds a full message snapshot.
|
|
10
10
|
* - errors: {type:"error", error:{type,message}}; may arrive mid-stream after HTTP 200.
|
|
11
11
|
*/
|
|
12
|
+
import { isTransientUpstreamStatus } from "../lib/upstream-retry";
|
|
12
13
|
|
|
13
14
|
type Rec = Record<string, unknown>;
|
|
14
15
|
|
|
@@ -202,12 +203,19 @@ export function responsesSseToAnthropicSse(
|
|
|
202
203
|
});
|
|
203
204
|
emit("message_stop", { type: "message_stop" });
|
|
204
205
|
};
|
|
205
|
-
|
|
206
|
+
// upstreamDerived: transient upstream statuses become overloaded_error so the
|
|
207
|
+
// Anthropic-SDK client retries with backoff; proxy-internal exceptions stay
|
|
208
|
+
// api_error — a deterministic ocx bug must not be masked as retryable
|
|
209
|
+
// (devlog/_plan/260716_claudecode_hardening/020). On win32 mid-stream socket
|
|
210
|
+
// resets reach the reader catch (no failed-tail relay) and stay api_error —
|
|
211
|
+
// same as today, deliberate residual.
|
|
212
|
+
const fail = (status: number, message: string, upstreamDerived = false) => {
|
|
206
213
|
if (terminated) return;
|
|
207
214
|
terminated = true;
|
|
208
215
|
ensureStarted();
|
|
209
216
|
closeOpenBlock();
|
|
210
|
-
|
|
217
|
+
const type = upstreamDerived && isTransientUpstreamStatus(status) ? "overloaded_error" : undefined;
|
|
218
|
+
emit("error", anthropicErrorBody(status, message, type));
|
|
211
219
|
};
|
|
212
220
|
|
|
213
221
|
const handleFrame = (eventName: string, data: Rec) => {
|
|
@@ -323,7 +331,10 @@ export function responsesSseToAnthropicSse(
|
|
|
323
331
|
const error = isRec(response.error) ? response.error : {};
|
|
324
332
|
const message = typeof error.message === "string" ? error.message : "upstream request failed";
|
|
325
333
|
const status = typeof error.status === "number" ? error.status : 500;
|
|
326
|
-
|
|
334
|
+
// status-absent response.failed (relaySseWithFailedTail synthetic tail) defaults
|
|
335
|
+
// to 500, which is in the transient set — the mid-stream reset shape maps to
|
|
336
|
+
// overloaded_error by design.
|
|
337
|
+
fail(status, message, true);
|
|
327
338
|
break;
|
|
328
339
|
}
|
|
329
340
|
default:
|
|
@@ -360,7 +371,7 @@ export function responsesSseToAnthropicSse(
|
|
|
360
371
|
// gateways that close such streams politely hand Claude Code an empty/partial
|
|
361
372
|
// turn with no retryable error — CLIProxyAPI#2189 failure pattern). Fail closed
|
|
362
373
|
// with a mid-stream Anthropic error event so the client can retry.
|
|
363
|
-
if (!cancelled) fail(502, "upstream stream ended before a terminal frame (truncated response)");
|
|
374
|
+
if (!cancelled) fail(502, "upstream stream ended before a terminal frame (truncated response)", true);
|
|
364
375
|
} catch (err) {
|
|
365
376
|
fail(500, err instanceof Error ? err.message : String(err));
|
|
366
377
|
} finally {
|
package/src/cli/index.ts
CHANGED
|
@@ -500,7 +500,7 @@ switch (command) {
|
|
|
500
500
|
case "logout": {
|
|
501
501
|
const { removeCredential } = await import("../oauth/store");
|
|
502
502
|
const name = (args[1] ?? "").trim().toLowerCase();
|
|
503
|
-
removeCredential(name);
|
|
503
|
+
await removeCredential(name);
|
|
504
504
|
console.log(`Logged out of ${name || "(none)"}.`);
|
|
505
505
|
break;
|
|
506
506
|
}
|
package/src/codex/auth-api.ts
CHANGED
|
@@ -237,9 +237,7 @@ async function fetchMainAccountInfo(forceRefresh = false): Promise<{ email: stri
|
|
|
237
237
|
updateAccountQuota(
|
|
238
238
|
MAIN_CODEX_ACCOUNT_ID,
|
|
239
239
|
result.quota.weeklyPercent,
|
|
240
|
-
result.quota.fiveHourPercent,
|
|
241
240
|
result.quota.weeklyResetAt,
|
|
242
|
-
result.quota.fiveHourResetAt,
|
|
243
241
|
result.quota.monthlyPercent,
|
|
244
242
|
result.quota.monthlyResetAt,
|
|
245
243
|
result.quota.resetCredits,
|
|
@@ -285,9 +283,7 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
|
|
|
285
283
|
updateAccountQuota(
|
|
286
284
|
accountId,
|
|
287
285
|
quota.weeklyPercent,
|
|
288
|
-
quota.fiveHourPercent,
|
|
289
286
|
quota.weeklyResetAt,
|
|
290
|
-
quota.fiveHourResetAt,
|
|
291
287
|
quota.monthlyPercent,
|
|
292
288
|
quota.monthlyResetAt,
|
|
293
289
|
quota.resetCredits,
|
|
@@ -574,6 +570,13 @@ export async function handleCodexAuthAPI(
|
|
|
574
570
|
const { startLoginFlow, getLoginStatus } = await import("../oauth");
|
|
575
571
|
const result = await startLoginFlow("chatgpt", { forceLogin: true });
|
|
576
572
|
|
|
573
|
+
// Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
|
|
574
|
+
// The GUI's window.open is popup-blocked because it runs after an await, not a direct click.
|
|
575
|
+
if (result.url) {
|
|
576
|
+
const { openUrl } = await import("../lib/open-url");
|
|
577
|
+
openUrl(result.url);
|
|
578
|
+
}
|
|
579
|
+
|
|
577
580
|
(async () => {
|
|
578
581
|
let completed = false;
|
|
579
582
|
for (let i = 0; i < 150; i++) {
|
|
@@ -644,9 +647,7 @@ export async function handleCodexAuthAPI(
|
|
|
644
647
|
updateAccountQuota(
|
|
645
648
|
accountId,
|
|
646
649
|
quota.weeklyPercent,
|
|
647
|
-
quota.fiveHourPercent,
|
|
648
650
|
quota.weeklyResetAt,
|
|
649
|
-
quota.fiveHourResetAt,
|
|
650
651
|
quota.monthlyPercent,
|
|
651
652
|
quota.monthlyResetAt,
|
|
652
653
|
quota.resetCredits,
|
package/src/codex/quota.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
export type StoredAccountQuota = {
|
|
2
2
|
weeklyPercent?: number;
|
|
3
|
-
fiveHourPercent?: number;
|
|
4
3
|
monthlyPercent?: number;
|
|
5
4
|
weeklyResetAt?: number;
|
|
6
|
-
fiveHourResetAt?: number;
|
|
7
5
|
monthlyResetAt?: number;
|
|
8
6
|
resetCredits?: number;
|
|
9
7
|
updatedAt: number;
|
|
@@ -47,48 +45,38 @@ function normalizeResetAt(value: unknown): number | undefined {
|
|
|
47
45
|
}
|
|
48
46
|
|
|
49
47
|
function hasKnownQuotaValue(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
|
|
50
|
-
return [quota.weeklyPercent, quota.
|
|
48
|
+
return [quota.weeklyPercent, quota.monthlyPercent]
|
|
51
49
|
.some(value => typeof value === "number" && Number.isFinite(value));
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
export function updateAccountQuota(
|
|
55
53
|
accountId: string,
|
|
56
54
|
weekly: unknown,
|
|
57
|
-
fiveHour: unknown,
|
|
58
55
|
weeklyResetAt?: unknown,
|
|
59
|
-
fiveHourResetAt?: unknown,
|
|
60
56
|
monthly?: unknown,
|
|
61
57
|
monthlyResetAt?: unknown,
|
|
62
58
|
resetCredits?: number,
|
|
63
59
|
): void {
|
|
64
60
|
const existing = accountQuota.get(accountId);
|
|
65
61
|
const nextWeekly = normalizeUsagePercent(weekly);
|
|
66
|
-
const nextFiveHour = normalizeUsagePercent(fiveHour);
|
|
67
62
|
const nextMonthly = normalizeUsagePercent(monthly);
|
|
68
|
-
if (nextWeekly === undefined &&
|
|
63
|
+
if (nextWeekly === undefined && nextMonthly === undefined && resetCredits === undefined) return;
|
|
69
64
|
|
|
70
65
|
const quota: StoredAccountQuota = {
|
|
71
66
|
...(existing?.weeklyPercent !== undefined ? { weeklyPercent: existing.weeklyPercent } : {}),
|
|
72
|
-
...(existing?.fiveHourPercent !== undefined ? { fiveHourPercent: existing.fiveHourPercent } : {}),
|
|
73
67
|
...(existing?.monthlyPercent !== undefined ? { monthlyPercent: existing.monthlyPercent } : {}),
|
|
74
68
|
...(existing?.weeklyResetAt !== undefined ? { weeklyResetAt: existing.weeklyResetAt } : {}),
|
|
75
|
-
...(existing?.fiveHourResetAt !== undefined ? { fiveHourResetAt: existing.fiveHourResetAt } : {}),
|
|
76
69
|
...(existing?.monthlyResetAt !== undefined ? { monthlyResetAt: existing.monthlyResetAt } : {}),
|
|
77
70
|
...(existing?.resetCredits !== undefined ? { resetCredits: existing.resetCredits } : {}),
|
|
78
71
|
updatedAt: Date.now(),
|
|
79
72
|
};
|
|
80
73
|
|
|
81
74
|
const nextWeeklyResetAt = normalizeResetAt(weeklyResetAt);
|
|
82
|
-
const nextFiveHourResetAt = normalizeResetAt(fiveHourResetAt);
|
|
83
75
|
const nextMonthlyResetAt = normalizeResetAt(monthlyResetAt);
|
|
84
76
|
if (nextWeekly !== undefined) {
|
|
85
77
|
quota.weeklyPercent = nextWeekly;
|
|
86
78
|
if (nextWeeklyResetAt !== undefined) quota.weeklyResetAt = nextWeeklyResetAt;
|
|
87
79
|
}
|
|
88
|
-
if (nextFiveHour !== undefined) {
|
|
89
|
-
quota.fiveHourPercent = nextFiveHour;
|
|
90
|
-
if (nextFiveHourResetAt !== undefined) quota.fiveHourResetAt = nextFiveHourResetAt;
|
|
91
|
-
}
|
|
92
80
|
if (nextMonthly !== undefined) {
|
|
93
81
|
quota.monthlyPercent = nextMonthly;
|
|
94
82
|
if (nextMonthlyResetAt !== undefined) quota.monthlyResetAt = nextMonthlyResetAt;
|
|
@@ -122,27 +110,25 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
|
|
|
122
110
|
|
|
123
111
|
const quota: Omit<StoredAccountQuota, "updatedAt"> = {};
|
|
124
112
|
const thirtyDayOnly = data.plan_type?.trim().toLowerCase() === "go" || data.plan_type?.trim().toLowerCase() === "free";
|
|
125
|
-
|
|
126
|
-
|
|
113
|
+
// primary_window was the 5h window; it now carries weekly data for GPT plans.
|
|
114
|
+
// secondary_window is the legacy weekly source; prefer primary when present.
|
|
115
|
+
const primaryPercent = normalizeUsagePercent(data.rate_limit.primary_window?.used_percent);
|
|
116
|
+
const secondaryPercent = normalizeUsagePercent(data.rate_limit.secondary_window?.used_percent);
|
|
117
|
+
const weeklyPercent = primaryPercent ?? secondaryPercent;
|
|
127
118
|
const monthlyPercent = normalizeUsagePercent(data.rate_limit.tertiary_window?.used_percent);
|
|
128
|
-
const
|
|
129
|
-
const
|
|
119
|
+
const primaryResetAt = normalizeResetAt(data.rate_limit.primary_window?.reset_at);
|
|
120
|
+
const secondaryResetAt = normalizeResetAt(data.rate_limit.secondary_window?.reset_at);
|
|
121
|
+
const weeklyResetAt = primaryPercent !== undefined ? primaryResetAt : secondaryResetAt;
|
|
130
122
|
const monthlyResetAt = normalizeResetAt(data.rate_limit.tertiary_window?.reset_at);
|
|
131
123
|
if (thirtyDayOnly) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
quota.monthlyPercent = goMonthlyPercent;
|
|
136
|
-
if (goMonthlyResetAt !== undefined) quota.monthlyResetAt = goMonthlyResetAt;
|
|
124
|
+
if (monthlyPercent !== undefined) {
|
|
125
|
+
quota.monthlyPercent = monthlyPercent;
|
|
126
|
+
if (monthlyResetAt !== undefined) quota.monthlyResetAt = monthlyResetAt;
|
|
137
127
|
}
|
|
138
128
|
} else if (weeklyPercent !== undefined) {
|
|
139
129
|
quota.weeklyPercent = weeklyPercent;
|
|
140
130
|
if (weeklyResetAt !== undefined) quota.weeklyResetAt = weeklyResetAt;
|
|
141
131
|
}
|
|
142
|
-
if (!thirtyDayOnly && fiveHourPercent !== undefined) {
|
|
143
|
-
quota.fiveHourPercent = fiveHourPercent;
|
|
144
|
-
if (fiveHourResetAt !== undefined) quota.fiveHourResetAt = fiveHourResetAt;
|
|
145
|
-
}
|
|
146
132
|
if (!thirtyDayOnly && monthlyPercent !== undefined) {
|
|
147
133
|
quota.monthlyPercent = monthlyPercent;
|
|
148
134
|
if (monthlyResetAt !== undefined) quota.monthlyResetAt = monthlyResetAt;
|
package/src/codex/routing.ts
CHANGED
|
@@ -80,7 +80,6 @@ export function getCodexUpstreamHealth(
|
|
|
80
80
|
|
|
81
81
|
export function computeCodexUsageScore(quota: {
|
|
82
82
|
weeklyPercent?: number;
|
|
83
|
-
fiveHourPercent?: number;
|
|
84
83
|
monthlyPercent?: number;
|
|
85
84
|
} | null, plan?: string | null): number {
|
|
86
85
|
if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
|
|
@@ -90,7 +89,7 @@ export function computeCodexUsageScore(quota: {
|
|
|
90
89
|
? quota.monthlyPercent
|
|
91
90
|
: CODEX_UNKNOWN_USAGE_SCORE;
|
|
92
91
|
}
|
|
93
|
-
const values = [quota.weeklyPercent, quota.
|
|
92
|
+
const values = [quota.weeklyPercent, quota.monthlyPercent]
|
|
94
93
|
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
|
95
94
|
return values.length > 0 ? Math.max(...values) : CODEX_UNKNOWN_USAGE_SCORE;
|
|
96
95
|
}
|
package/src/lib/abort.ts
CHANGED
|
@@ -14,6 +14,61 @@ export interface ClearableDeadline {
|
|
|
14
14
|
clear: () => void;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export interface IdleDeadline {
|
|
18
|
+
/** (Re-)arm the timer for one idle window. Call when a wait for progress BEGINS. No-op after fire/cancel. */
|
|
19
|
+
reset: () => void;
|
|
20
|
+
/** Disarm the timer WITHOUT retiring the deadline (call when the awaited progress arrives). No-op after fire/cancel. */
|
|
21
|
+
pause: () => void;
|
|
22
|
+
/** Retire permanently (success/teardown paths). Idempotent. */
|
|
23
|
+
cancel: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resettable inactivity deadline (devlog 260716_passthrough_followups/010).
|
|
28
|
+
*
|
|
29
|
+
* Fires `onIdle` at most ONCE after `idleMs` elapses with no `reset()`/`pause()`.
|
|
30
|
+
* Contract:
|
|
31
|
+
* - `idleMs <= 0` returns an inert no-op deadline — the 0-disable responsibility
|
|
32
|
+
* lives here so callers cannot mis-handle it.
|
|
33
|
+
* - The timer starts DISARMED: callers arm it with `reset()` when a wait begins and
|
|
34
|
+
* `pause()` it when the wait settles, so pull-based relays never count downstream
|
|
35
|
+
* backpressure (no pending read) as upstream inactivity.
|
|
36
|
+
* - First terminal wins: after `onIdle` runs or `cancel()` is called, every method
|
|
37
|
+
* is a no-op and `onIdle` never runs again.
|
|
38
|
+
* - Never linked to fetch signals — the consumer decides how to kill its stream
|
|
39
|
+
* (e.g. reader.cancel), keeping body-lifetime semantics unchanged.
|
|
40
|
+
*/
|
|
41
|
+
export function idleDeadline(idleMs: number, onIdle: () => void): IdleDeadline {
|
|
42
|
+
if (idleMs <= 0) return { reset: () => {}, pause: () => {}, cancel: () => {} };
|
|
43
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
44
|
+
let done = false;
|
|
45
|
+
const disarm = () => {
|
|
46
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
47
|
+
timer = undefined;
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
reset: () => {
|
|
51
|
+
if (done) return;
|
|
52
|
+
disarm();
|
|
53
|
+
timer = setTimeout(() => {
|
|
54
|
+
if (done) return;
|
|
55
|
+
done = true;
|
|
56
|
+
timer = undefined;
|
|
57
|
+
onIdle();
|
|
58
|
+
}, idleMs);
|
|
59
|
+
},
|
|
60
|
+
pause: () => {
|
|
61
|
+
if (done) return;
|
|
62
|
+
disarm();
|
|
63
|
+
},
|
|
64
|
+
cancel: () => {
|
|
65
|
+
if (done) return;
|
|
66
|
+
done = true;
|
|
67
|
+
disarm();
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
17
72
|
/**
|
|
18
73
|
* Response-header deadline whose timer can be cleared without severing body-lifetime cancellation.
|
|
19
74
|
*
|
|
@@ -21,6 +21,25 @@ const RESET_RETRY_MAX_ATTEMPTS = 3;
|
|
|
21
21
|
const RESET_RETRY_BASE_DELAY_MS = 150;
|
|
22
22
|
const RESET_RETRY_MAX_DELAY_MS = 1_000;
|
|
23
23
|
|
|
24
|
+
// Transient-5xx status retry layer (pre-stream only; devlog/_plan/260716_claudecode_hardening/010).
|
|
25
|
+
const TRANSIENT_RETRY_MAX_ATTEMPTS = 3; // 1 initial + 2 retries
|
|
26
|
+
const TRANSIENT_RETRY_BASE_DELAY_MS = 400;
|
|
27
|
+
const TRANSIENT_RETRY_MAX_DELAY_MS = 5_000;
|
|
28
|
+
// A failed attempt slower than this is the "slow 502" incident shape (191s observed on
|
|
29
|
+
// 2026-07-15): retrying it only duplicates upstream load past client timeouts — return it.
|
|
30
|
+
const TRANSIENT_RETRY_SLOW_ATTEMPT_MS = 15_000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Upstream statuses treated as transient: gateway errors and Cloudflare 52x.
|
|
34
|
+
* 500 is included per the OpenAI SDK default (auto-retries >=500; Tier-2 proven in
|
|
35
|
+
* devlog/260716_ocx_claude_sol_502_midstream/02). 507 was observed in the 48h ledger
|
|
36
|
+
* but is deliberately excluded (storage-class, not gateway-transient).
|
|
37
|
+
*/
|
|
38
|
+
export function isTransientUpstreamStatus(status: number): boolean {
|
|
39
|
+
return status === 500 || status === 502 || status === 503 || status === 504
|
|
40
|
+
|| status === 520 || status === 521 || status === 522;
|
|
41
|
+
}
|
|
42
|
+
|
|
24
43
|
export interface RetryBackoffOptions {
|
|
25
44
|
baseDelayMs: number;
|
|
26
45
|
maxDelayMs: number;
|
|
@@ -121,6 +140,11 @@ export interface ResetRetryOptions {
|
|
|
121
140
|
attempts?: number;
|
|
122
141
|
}
|
|
123
142
|
|
|
143
|
+
export interface TransientRetryOptions extends ResetRetryOptions {
|
|
144
|
+
/** Test seam: per-attempt slow budget override (defaults to TRANSIENT_RETRY_SLOW_ATTEMPT_MS). */
|
|
145
|
+
slowAttemptMs?: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
124
148
|
/**
|
|
125
149
|
* Run `doFetch`, retrying only connection-reset-shaped rejections (see
|
|
126
150
|
* isConnectionResetError) with jittered backoff. The caller's thunk must be replay-safe
|
|
@@ -150,3 +174,41 @@ export async function fetchWithResetRetry(
|
|
|
150
174
|
}
|
|
151
175
|
throw lastError ?? new Error("upstream fetch failed");
|
|
152
176
|
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* fetchWithResetRetry plus a transient-5xx status retry layer, PRE-STREAM only: a
|
|
180
|
+
* returned Response has by definition not been relayed to the client yet, so replaying
|
|
181
|
+
* the (string-body) request is safe. The failed attempt's body is cancelled before the
|
|
182
|
+
* retry; every returned response (ok, non-transient, aborted, slow, exhausted) keeps
|
|
183
|
+
* its body intact. Honors Retry-After via retryBackoffDelayMs.
|
|
184
|
+
*
|
|
185
|
+
* A failed attempt slower than the slow budget is returned as-is (slow-502 shape);
|
|
186
|
+
* note `opts.attempts` is shared with the inner reset layer (no caller passes it today).
|
|
187
|
+
*/
|
|
188
|
+
export async function fetchWithTransientRetry(
|
|
189
|
+
doFetch: () => Promise<Response>,
|
|
190
|
+
opts: TransientRetryOptions = {},
|
|
191
|
+
): Promise<Response> {
|
|
192
|
+
const attempts = Math.max(1, opts.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS);
|
|
193
|
+
const slowAttemptMs = opts.slowAttemptMs ?? TRANSIENT_RETRY_SLOW_ATTEMPT_MS;
|
|
194
|
+
let attemptStart = Date.now();
|
|
195
|
+
let res = await fetchWithResetRetry(doFetch, opts);
|
|
196
|
+
for (let attempt = 0; attempt < attempts - 1; attempt++) {
|
|
197
|
+
if (res.ok || !isTransientUpstreamStatus(res.status)) return res;
|
|
198
|
+
if (opts.abortSignal?.aborted) return res;
|
|
199
|
+
if (Date.now() - attemptStart > slowAttemptMs) return res;
|
|
200
|
+
console.warn(
|
|
201
|
+
`[upstream-retry] transient ${res.status}${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`,
|
|
202
|
+
);
|
|
203
|
+
const delay = retryBackoffDelayMs(attempt, {
|
|
204
|
+
baseDelayMs: TRANSIENT_RETRY_BASE_DELAY_MS,
|
|
205
|
+
maxDelayMs: TRANSIENT_RETRY_MAX_DELAY_MS,
|
|
206
|
+
headers: res.headers,
|
|
207
|
+
});
|
|
208
|
+
cancelResponseBodyBestEffort(res);
|
|
209
|
+
await sleepWithAbort(delay, opts.abortSignal);
|
|
210
|
+
attemptStart = Date.now();
|
|
211
|
+
res = await fetchWithResetRetry(doFetch, opts);
|
|
212
|
+
}
|
|
213
|
+
return res;
|
|
214
|
+
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -3,8 +3,8 @@ 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 } from "./store";
|
|
7
|
-
import { loginXai, refreshXaiToken } from "./xai";
|
|
6
|
+
import { getAccountCredential, getAccountSet, saveAccountCredential, saveCredential, markAccountNeedsReauth, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration } from "./store";
|
|
7
|
+
import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai";
|
|
8
8
|
import { ANTHROPIC_OAUTH_BETA, loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
|
9
9
|
import { loginKimi, refreshKimiToken } from "./kimi";
|
|
10
10
|
import { loginKiro, readKiroCliSqlite, refreshKiroToken } from "./kiro";
|
|
@@ -14,9 +14,22 @@ import { loginCursor, refreshCursorToken } from "./cursor";
|
|
|
14
14
|
import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
|
|
15
15
|
import { effectiveGoogleMode } from "../providers/registry";
|
|
16
16
|
import { resolveProviderTransport } from "../providers/xai-transport";
|
|
17
|
+
import { detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
|
|
17
18
|
|
|
18
19
|
const REFRESH_SKEW_MS = 60_000;
|
|
19
|
-
|
|
20
|
+
export interface OAuthAccessSnapshot {
|
|
21
|
+
provider: string;
|
|
22
|
+
accountId: string;
|
|
23
|
+
generation: string;
|
|
24
|
+
accessToken: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const tokenRefreshes = new Map<string, Promise<OAuthAccessSnapshot>>();
|
|
28
|
+
const XAI_PERMANENT_FAILURE_TTL_MS=30_000;
|
|
29
|
+
const permanentRefreshFailures=new Map<string,number>();
|
|
30
|
+
interface XaiRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void> }
|
|
31
|
+
function verdictKey(p:string,a:string,c:OAuthCredentials){return `${p}\0${a}\0${credentialGeneration(c)}`;}
|
|
32
|
+
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;}
|
|
20
33
|
|
|
21
34
|
export interface LoginOpts { forceLogin?: boolean }
|
|
22
35
|
|
|
@@ -139,36 +152,74 @@ export class OAuthLoginRequiredError extends Error {
|
|
|
139
152
|
}
|
|
140
153
|
}
|
|
141
154
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
155
|
+
function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials): OAuthAccessSnapshot {
|
|
156
|
+
return {
|
|
157
|
+
provider,
|
|
158
|
+
accountId,
|
|
159
|
+
generation: credentialGeneration(cred),
|
|
160
|
+
accessToken: cred.access,
|
|
161
|
+
};
|
|
149
162
|
}
|
|
150
163
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
export async function getValidAccessTokenForAccount(provider: string, accountId: string): Promise<string> {
|
|
164
|
+
async function resolveAccessSnapshotForAccount(
|
|
165
|
+
provider: string,
|
|
166
|
+
accountId: string,
|
|
167
|
+
rejectedGeneration?: string,
|
|
168
|
+
): Promise<OAuthAccessSnapshot> {
|
|
157
169
|
const def = OAUTH_PROVIDERS[provider];
|
|
158
170
|
if (!def) throw new UnsupportedOAuthProviderError(provider);
|
|
159
171
|
const cred = getAccountCredential(provider, accountId);
|
|
160
172
|
if (!cred) throw new OAuthLoginRequiredError(provider);
|
|
161
|
-
|
|
173
|
+
const current = accessSnapshot(provider, accountId, cred);
|
|
174
|
+
if (rejectedGeneration !== undefined && current.generation !== rejectedGeneration) return current;
|
|
175
|
+
if (rejectedGeneration === undefined && cred.expires > Date.now() + REFRESH_SKEW_MS) return current;
|
|
176
|
+
|
|
162
177
|
const key = `${provider}\u0000${accountId}`;
|
|
163
178
|
const existing = tokenRefreshes.get(key);
|
|
164
179
|
if (existing) return existing;
|
|
165
|
-
|
|
180
|
+
|
|
181
|
+
const refresh = (async (): Promise<OAuthAccessSnapshot> => {
|
|
182
|
+
const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred);
|
|
183
|
+
const persisted = getAccountCredential(provider, accountId);
|
|
184
|
+
if (!persisted) throw new OAuthLoginRequiredError(provider);
|
|
185
|
+
if (persisted.access !== accessToken) {
|
|
186
|
+
throw new Error(`OAuth refresh persisted an unexpected access token for ${provider}`);
|
|
187
|
+
}
|
|
188
|
+
return accessSnapshot(provider, accountId, persisted);
|
|
189
|
+
})().finally(() => {
|
|
166
190
|
if (tokenRefreshes.get(key) === refresh) tokenRefreshes.delete(key);
|
|
167
191
|
});
|
|
168
192
|
tokenRefreshes.set(key, refresh);
|
|
169
193
|
return refresh;
|
|
170
194
|
}
|
|
171
195
|
|
|
196
|
+
export async function getValidAccessTokenSnapshot(provider: string): Promise<OAuthAccessSnapshot> {
|
|
197
|
+
const set = getAccountSet(provider);
|
|
198
|
+
if (!set) throw new OAuthLoginRequiredError(provider);
|
|
199
|
+
return resolveAccessSnapshotForAccount(provider, set.activeAccountId);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function forceRefreshOAuthAccessSnapshot(
|
|
203
|
+
rejected: OAuthAccessSnapshot,
|
|
204
|
+
): Promise<OAuthAccessSnapshot> {
|
|
205
|
+
if (rejected.provider !== "xai") throw new UnsupportedOAuthProviderError(rejected.provider);
|
|
206
|
+
return resolveAccessSnapshotForAccount(rejected.provider, rejected.accountId, rejected.generation);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Return a valid access token for the ACTIVE account, refreshing + persisting if expired. */
|
|
210
|
+
export async function getValidAccessToken(provider: string): Promise<string> {
|
|
211
|
+
return (await getValidAccessTokenSnapshot(provider)).accessToken;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Account-scoped token resolver (multiauth): refresh is single-flighted per
|
|
216
|
+
* (provider, account), and the rotated credential is persisted for THAT account only —
|
|
217
|
+
* a guardian refresh of a background account never switches the active account.
|
|
218
|
+
*/
|
|
219
|
+
export async function getValidAccessTokenForAccount(provider: string, accountId: string): Promise<string> {
|
|
220
|
+
return (await resolveAccessSnapshotForAccount(provider, accountId)).accessToken;
|
|
221
|
+
}
|
|
222
|
+
|
|
172
223
|
function readFreshKiroCliCredential(): OAuthCredentials | undefined {
|
|
173
224
|
const imported = readKiroCliSqlite();
|
|
174
225
|
if (!imported || imported.expires <= Date.now() + REFRESH_SKEW_MS) return undefined;
|
|
@@ -180,6 +231,10 @@ function isTerminalRefreshError(err: unknown): boolean {
|
|
|
180
231
|
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
181
232
|
return msg.includes("invalid_grant") || msg.includes("refresh_token_reused") || msg.includes("revoked");
|
|
182
233
|
}
|
|
234
|
+
function terminal(error:unknown):boolean{return error instanceof XaiTokenRequestError?["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??""):isTerminalRefreshError(error);}
|
|
235
|
+
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;}
|
|
236
|
+
function merged(fresh:OAuthCredentials,previous:OAuthCredentials):OAuthCredentials{return{...fresh,source:previous.source==="local-cli"?"oauth":fresh.source??previous.source??"oauth",...(fresh.projectId===undefined&&previous.projectId?{projectId:previous.projectId}:{}),...(fresh.email===undefined&&previous.email?{email:previous.email}:{}),...(fresh.accountId===undefined&&previous.accountId?{accountId:previous.accountId}:{})};}
|
|
237
|
+
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();}}
|
|
183
238
|
|
|
184
239
|
async function refreshAndPersistAccessToken(
|
|
185
240
|
provider: string,
|
|
@@ -193,17 +248,20 @@ async function refreshAndPersistAccessToken(
|
|
|
193
248
|
if (provider === "kiro" && isActive) {
|
|
194
249
|
const imported = readFreshKiroCliCredential();
|
|
195
250
|
if (imported) {
|
|
196
|
-
saveCredential(provider, imported);
|
|
251
|
+
await saveCredential(provider, imported);
|
|
197
252
|
return imported.access;
|
|
198
253
|
}
|
|
199
254
|
}
|
|
255
|
+
if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred);
|
|
200
256
|
try {
|
|
201
257
|
const fresh = await def.refresh(cred.refresh);
|
|
258
|
+
const detachedLocalCli = provider === "xai" && cred.source === "local-cli";
|
|
259
|
+
if (detachedLocalCli) console.warn(XAI_LOCAL_CLI_DETACH_WARNING);
|
|
202
260
|
// Persist to THIS account (rotation-safe: new refresh token hits disk before use) without
|
|
203
261
|
// touching activeAccountId.
|
|
204
|
-
saveAccountCredential(provider, accountId, {
|
|
262
|
+
await saveAccountCredential(provider, accountId, {
|
|
205
263
|
...fresh,
|
|
206
|
-
source: fresh.source ?? cred.source ?? "oauth",
|
|
264
|
+
source: detachedLocalCli ? "oauth" : fresh.source ?? cred.source ?? "oauth",
|
|
207
265
|
// Preserve a previously-discovered project id when a refresh-time re-discovery comes back empty
|
|
208
266
|
// (e.g. a transient network blip), so Antigravity does not lose its CCA project across refresh.
|
|
209
267
|
...(fresh.projectId === undefined && cred.projectId ? { projectId: cred.projectId } : {}),
|
|
@@ -216,12 +274,12 @@ async function refreshAndPersistAccessToken(
|
|
|
216
274
|
if (provider === "kiro" && isActive) {
|
|
217
275
|
const imported = readFreshKiroCliCredential();
|
|
218
276
|
if (imported) {
|
|
219
|
-
saveCredential(provider, imported);
|
|
277
|
+
await saveCredential(provider, imported);
|
|
220
278
|
return imported.access;
|
|
221
279
|
}
|
|
222
280
|
}
|
|
223
281
|
if (isTerminalRefreshError(err)) {
|
|
224
|
-
markAccountNeedsReauth(provider, accountId, true);
|
|
282
|
+
await markAccountNeedsReauth(provider, accountId, true);
|
|
225
283
|
throw new OAuthLoginRequiredError(provider);
|
|
226
284
|
}
|
|
227
285
|
throw err;
|
|
@@ -352,7 +410,7 @@ export async function runLogin(provider: string, ctrl: OAuthController, opts?: L
|
|
|
352
410
|
if (!def) throw new UnsupportedOAuthProviderError(provider);
|
|
353
411
|
const rawCred = await def.login(ctrl, opts);
|
|
354
412
|
const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" };
|
|
355
|
-
saveCredential(provider, cred);
|
|
413
|
+
await saveCredential(provider, cred);
|
|
356
414
|
const config = loadConfig();
|
|
357
415
|
upsertOAuthProvider(config, provider);
|
|
358
416
|
saveConfig(config);
|
|
@@ -13,7 +13,7 @@ const XAI_AUTH_KEY_PREFIX = "https://auth.x.ai::";
|
|
|
13
13
|
const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
14
14
|
|
|
15
15
|
export function detectGrokCliToken(): OAuthCredentials | null {
|
|
16
|
-
const authPath = join(homedir(), ".grok", "auth.json");
|
|
16
|
+
const authPath = join(process.env.HOME ?? homedir(), ".grok", "auth.json");
|
|
17
17
|
if (!existsSync(authPath)) return null;
|
|
18
18
|
|
|
19
19
|
try {
|
|
@@ -39,6 +39,28 @@ export function detectGrokCliToken(): OAuthCredentials | null {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export function hasComparableGrokIdentity(stored: OAuthCredentials, disk: OAuthCredentials): boolean {
|
|
43
|
+
return Boolean((stored.accountId && disk.accountId) || (stored.email && disk.email));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isSameGrokIdentity(stored: OAuthCredentials, disk: OAuthCredentials): boolean {
|
|
47
|
+
if (stored.accountId && disk.accountId) return stored.accountId === disk.accountId;
|
|
48
|
+
if (stored.email && disk.email) return stored.email.toLowerCase() === disk.email.toLowerCase();
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function shouldAdoptGrokGeneration(
|
|
53
|
+
stored: OAuthCredentials,
|
|
54
|
+
disk: OAuthCredentials,
|
|
55
|
+
now = Date.now(),
|
|
56
|
+
refreshSkewMs = 60_000,
|
|
57
|
+
): boolean {
|
|
58
|
+
if (disk.expires <= now + refreshSkewMs) return false;
|
|
59
|
+
const bothExpiriesExist = stored.expires > 0 && disk.expires > 0;
|
|
60
|
+
if (bothExpiriesExist) return disk.expires >= stored.expires;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
42
64
|
/** Claude Code config dir: `CLAUDE_CONFIG_DIR` override, else `~/.claude`. */
|
|
43
65
|
function claudeConfigDir(): string {
|
|
44
66
|
const explicit = process.env.CLAUDE_CONFIG_DIR?.trim();
|