@bitkyc08/opencodex 2.7.29 → 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.
Files changed (41) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/README.ru.md +479 -0
  4. package/README.zh-CN.md +1 -1
  5. package/gui/dist/assets/index-BPa0R6EN.js +46 -0
  6. package/gui/dist/assets/index-BY7KvJRB.css +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/cursor/cursor-errors.ts +7 -2
  10. package/src/adapters/cursor/discovery.ts +2 -2
  11. package/src/adapters/cursor/request-builder.ts +90 -6
  12. package/src/adapters/cursor/tool-definitions.ts +23 -6
  13. package/src/adapters/google.ts +7 -0
  14. package/src/codex/auth-api.ts +18 -0
  15. package/src/codex/catalog.ts +39 -11
  16. package/src/codex/routing.ts +85 -4
  17. package/src/combos/index.ts +1 -1
  18. package/src/combos/request.ts +26 -4
  19. package/src/combos/types.ts +4 -4
  20. package/src/lib/errors.ts +4 -0
  21. package/src/lib/upstream-retry.ts +21 -0
  22. package/src/lib/winsw.ts +7 -1
  23. package/src/oauth/anthropic.ts +23 -1
  24. package/src/oauth/index.ts +83 -4
  25. package/src/oauth/local-token-detect.ts +3 -0
  26. package/src/oauth/store.ts +32 -0
  27. package/src/providers/antigravity-models.ts +23 -12
  28. package/src/providers/base-url-choices.ts +18 -0
  29. package/src/providers/registry.ts +122 -7
  30. package/src/responses/parser.ts +11 -6
  31. package/src/router.ts +6 -1
  32. package/src/server/index.ts +3 -3
  33. package/src/server/relay.ts +31 -5
  34. package/src/server/request-log.ts +12 -0
  35. package/src/server/responses.ts +111 -27
  36. package/src/service.ts +12 -3
  37. package/src/storage/scanner.ts +18 -6
  38. package/src/types.ts +4 -2
  39. package/src/usage/expected-prices.ts +12 -9
  40. package/gui/dist/assets/index-CMKZnkG9.js +0 -40
  41. package/gui/dist/assets/index-DyBPh28A.css +0 -1
@@ -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) && isCodexAccountUsable(config, accountId);
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 cooldownUntil = getCodexAccountCooldownUntil(accountId, now) ?? undefined;
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: stale ? 1 : (current?.consecutiveFailures ?? 0) + 1,
527
+ consecutiveFailures,
462
528
  lastFailureStatus,
463
529
  lastFailureAt: now,
464
- ...(cooldownUntil ? { cooldownUntil } : {}),
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
 
@@ -1,5 +1,4 @@
1
1
  export {
2
- COMBO_DEFAULT_EFFORT,
3
2
  COMBO_NAMESPACE,
4
3
  comboConfigError,
5
4
  comboConfigIssues,
@@ -34,4 +33,5 @@ export {
34
33
  export {
35
34
  comboIdFromRawBody,
36
35
  concreteComboRequestBody,
36
+ resetComboEffortWarningStateForTests,
37
37
  } from "./request";
@@ -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
- if (reasoning === undefined) {
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;
@@ -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 ?? COMBO_DEFAULT_EFFORT,
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 ?? COMBO_DEFAULT_EFFORT;
182
+ const value: unknown = combos[id]!.defaultEffort ?? null;
183
183
  return typeof value === "string" && isCodexReasoningEffort(value)
184
184
  ? value as OcxComboDefaultEffort
185
185
  : null;
package/src/lib/errors.ts CHANGED
@@ -99,6 +99,9 @@ export function classifyError(status: number, type: string, message: string): Oc
99
99
  ) {
100
100
  return { message, type: "invalid_request_error", code: "context_length_exceeded" };
101
101
  }
102
+ if (text.includes("cursor resource limit exceeded")) {
103
+ return { message, type: "invalid_request_error", code: "tool_catalog_too_large" };
104
+ }
102
105
  if (
103
106
  text.includes("insufficient_quota") ||
104
107
  text.includes("exceeded your current quota") ||
@@ -199,6 +202,7 @@ export function inferHttpStatusFromAdapterMessage(message: string): number {
199
202
  const lower = message.toLowerCase();
200
203
  // Client aborts (e.g. mid web-search loop) must not look like upstream 502s in /api/logs.
201
204
  if (isClientClosedMessage(lower)) return 499;
205
+ if (lower.includes("cursor resource limit exceeded")) return 400;
202
206
  if (
203
207
  lower.includes("resource_exhausted") ||
204
208
  lower.includes("resource exhausted") ||
@@ -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
- if (e.status === 1060 || /FAILED 1060/i.test(text)) return false;
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
  }
@@ -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
- throw new Error(`Anthropic OAuth HTTP ${response.status}: ${responseBody}`);
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
  }
@@ -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{return error instanceof XaiTokenRequestError?["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??""):isTerminalRefreshError(error);}
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
 
@@ -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 are the CCA WIRE ids followed by CLIProxyAPI-style
5
- // client aliases. The CCA envelope's `model` field must receive the wire id (for example
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.5-flash-low",
9
- "gemini-3-flash-agent",
10
- "gemini-3.5-flash-extra-low",
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
- export const ANTIGRAVITY_MODEL_ALIASES: Record<string, string> = {
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(ANTIGRAVITY_MODEL_ALIASES),
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.5-flash-low": 1_048_576,
33
- "gemini-3-flash-agent": 1_048_576,
34
- "gemini-3.5-flash-extra-low": 1_048_576,
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,
@@ -21,6 +21,24 @@ export const QWEN_CLOUD_BASE_URL_CHOICES: readonly ProviderBaseUrlChoice[] = [
21
21
  { id: "custom", label: "Custom" },
22
22
  ];
23
23
 
24
+ /**
25
+ * Alibaba Token Plan International (ap-southeast-1) endpoint presets.
26
+ * Same product as the Beijing Token Plan but for international accounts.
27
+ * Note: ALIBABA_INTL_TOKEN_PLAN_BASE_URL intentionally duplicates
28
+ * QWEN_CLOUD_TOKEN_PLAN_BASE_URL — same host, different product branding
29
+ * and model lineup. Kept as a separate constant for clarity.
30
+ */
31
+ export const ALIBABA_INTL_TOKEN_PLAN_BASE_URL =
32
+ "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1";
33
+ export const ALIBABA_INTL_PAYG_BASE_URL =
34
+ "https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
35
+
36
+ export const ALIBABA_INTL_BASE_URL_CHOICES: readonly ProviderBaseUrlChoice[] = [
37
+ { id: "token-plan", label: "Token plan", baseUrl: ALIBABA_INTL_TOKEN_PLAN_BASE_URL },
38
+ { id: "payg", label: "Pay as you go", baseUrl: ALIBABA_INTL_PAYG_BASE_URL },
39
+ { id: "custom", label: "Custom" },
40
+ ];
41
+
24
42
  /** Match a saved baseUrl to a known choice id (`custom` when it does not match). */
25
43
  export function matchBaseUrlChoice(
26
44
  choices: readonly ProviderBaseUrlChoice[],