@bitkyc08/opencodex 2.7.40 → 2.7.41

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 (110) hide show
  1. package/README.md +63 -12
  2. package/assets/claude-code-models.gif +0 -0
  3. package/gui/dist/assets/index-B2J4t3te.css +1 -0
  4. package/gui/dist/assets/index-BmvM6wRb.js +65 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -2
  7. package/src/adapters/google.ts +48 -9
  8. package/src/adapters/kiro-events.ts +15 -3
  9. package/src/adapters/kiro.ts +292 -28
  10. package/src/adapters/openai-chat.ts +63 -15
  11. package/src/adapters/openai-responses.ts +18 -0
  12. package/src/bridge.ts +76 -21
  13. package/src/chat/outbound.ts +66 -34
  14. package/src/claude/auth-detect.ts +229 -0
  15. package/src/claude/auth-mode-migration.ts +32 -0
  16. package/src/claude/auth-mode.ts +62 -0
  17. package/src/claude/desktop-3p-guard.ts +35 -0
  18. package/src/claude/desktop-3p.ts +121 -21
  19. package/src/claude/desktop-health.ts +26 -0
  20. package/src/claude/desktop-profile.ts +263 -0
  21. package/src/claude/inbound-debug.ts +4 -0
  22. package/src/claude/model-info.ts +9 -3
  23. package/src/cli/account-extended.ts +34 -0
  24. package/src/cli/account.ts +3 -1
  25. package/src/cli/claude-desktop.ts +152 -0
  26. package/src/cli/claude.ts +37 -3
  27. package/src/cli/doctor.ts +187 -6
  28. package/src/cli/help.ts +13 -1
  29. package/src/cli/index.ts +151 -57
  30. package/src/cli/status-oauth.ts +68 -0
  31. package/src/cli/status.ts +4 -0
  32. package/src/codex/account-lifecycle.ts +31 -0
  33. package/src/codex/auth-api.ts +133 -26
  34. package/src/codex/auth-collision.ts +55 -11
  35. package/src/codex/auth-context.ts +50 -6
  36. package/src/codex/catalog/provider-fetch.ts +6 -2
  37. package/src/codex/home.ts +61 -1
  38. package/src/codex/inject.ts +28 -66
  39. package/src/codex/injected-marker.ts +72 -0
  40. package/src/codex/journal.ts +39 -3
  41. package/src/codex/main-account-cache.ts +25 -0
  42. package/src/codex/model-cache.ts +20 -1
  43. package/src/codex/paths.ts +5 -0
  44. package/src/codex/routing.ts +138 -11
  45. package/src/codex/subagent-model-fallback.ts +455 -0
  46. package/src/codex/sync.ts +17 -0
  47. package/src/combos/failover.ts +10 -2
  48. package/src/combos/index.ts +1 -0
  49. package/src/combos/types.ts +9 -0
  50. package/src/config.ts +117 -0
  51. package/src/grok/inject.ts +339 -0
  52. package/src/grok/status.ts +88 -0
  53. package/src/grok/sync.ts +66 -0
  54. package/src/lib/destination-policy.ts +13 -0
  55. package/src/lib/errors.ts +59 -0
  56. package/src/lib/privacy.ts +9 -0
  57. package/src/lib/process-control.ts +57 -4
  58. package/src/oauth/health.ts +375 -0
  59. package/src/oauth/index.ts +69 -22
  60. package/src/oauth/kiro.ts +5 -3
  61. package/src/oauth/log.ts +48 -0
  62. package/src/oauth/store.ts +55 -6
  63. package/src/providers/alibaba-region-backup.ts +75 -0
  64. package/src/providers/alibaba-region-migration.ts +143 -0
  65. package/src/providers/alibaba-region-startup.ts +36 -0
  66. package/src/providers/api-keys.ts +5 -5
  67. package/src/providers/derive.ts +22 -1
  68. package/src/providers/free-directory.ts +181 -0
  69. package/src/providers/key-failover.ts +2 -2
  70. package/src/providers/kiro-models.ts +3 -2
  71. package/src/providers/openai-tiers.ts +1 -1
  72. package/src/providers/provider-id-rewrite.ts +150 -0
  73. package/src/providers/registry.ts +49 -2
  74. package/src/responses/parser.ts +49 -20
  75. package/src/responses/state.ts +156 -2
  76. package/src/router.ts +73 -5
  77. package/src/server/chat-completions.ts +78 -22
  78. package/src/server/claude-messages.ts +8 -0
  79. package/src/server/images.ts +2 -1
  80. package/src/server/index.ts +50 -30
  81. package/src/server/live.ts +2 -1
  82. package/src/server/management/agent-settings-routes.ts +337 -18
  83. package/src/server/management/api-access.ts +141 -0
  84. package/src/server/management/combo-routes.ts +3 -3
  85. package/src/server/management/config-routes.ts +4 -4
  86. package/src/server/management/logs-usage-routes.ts +64 -4
  87. package/src/server/management/model-routes.ts +114 -8
  88. package/src/server/management/oauth-account-routes.ts +31 -5
  89. package/src/server/management/provider-routes.ts +42 -11
  90. package/src/server/management/shared.ts +81 -2
  91. package/src/server/management/system-routes.ts +6 -1
  92. package/src/server/management-api.ts +24 -7
  93. package/src/server/port-reclaim.ts +261 -0
  94. package/src/server/request-log.ts +11 -5
  95. package/src/server/responses/collaboration.ts +11 -6
  96. package/src/server/responses/compact.ts +2 -1
  97. package/src/server/responses/core.ts +358 -155
  98. package/src/server/responses/passthrough-error.ts +53 -0
  99. package/src/server/search.ts +2 -1
  100. package/src/server/system-env.ts +23 -6
  101. package/src/server/windows-tcp-drop.ts +174 -0
  102. package/src/service.ts +43 -3
  103. package/src/types.ts +74 -6
  104. package/src/update/index.ts +30 -19
  105. package/src/update/job.ts +30 -15
  106. package/src/usage/log.ts +158 -2
  107. package/src/usage/summary.ts +8 -4
  108. package/src/web-search/loop.ts +4 -1
  109. package/gui/dist/assets/index-CMip1DzF.css +0 -1
  110. package/gui/dist/assets/index-cydcmbzC.js +0 -52
@@ -16,6 +16,27 @@ import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers
16
16
  import { effectiveGoogleMode } from "../providers/registry";
17
17
  import { resolveProviderTransport } from "../providers/xai-transport";
18
18
  import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
19
+ import { logOAuthEvent } from "./log";
20
+ export {
21
+ CODEX_HEALTH_UNAVAILABLE_NOTE,
22
+ MASKED_ACCOUNT_FALLBACK,
23
+ collectOAuthHealthEntries,
24
+ collectOAuthHealthEntriesForCli,
25
+ detectOAuthWarning,
26
+ oauthAccountHealthFields,
27
+ oauthHealthLabel,
28
+ oauthHealthSummary,
29
+ projectCodexAccountHealth,
30
+ projectOAuthAccountHealth,
31
+ projectStoredOAuthAccountHealth,
32
+ type CodexHealthSource,
33
+ type OAuthAccountHealth,
34
+ type OAuthAccountHealthFields,
35
+ type OAuthCliHealthReport,
36
+ type OAuthHealthEntry,
37
+ type OAuthHealthLabel,
38
+ } from "./health";
39
+ export { OAUTH_REFRESH_LOCK_WAIT_MS, peekAuthStore, peekOAuthRefreshIntent } from "./store";
19
40
 
20
41
  const REFRESH_SKEW_MS = 60_000;
21
42
  export interface OAuthAccessSnapshot {
@@ -30,6 +51,7 @@ const XAI_PERMANENT_FAILURE_TTL_MS=30_000;
30
51
  const permanentRefreshFailures=new Map<string,number>();
31
52
  interface XaiRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void> }
32
53
  interface AnthropicRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void> }
54
+ interface GenericRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; afterPrePersistRead?:()=>void|Promise<void> }
33
55
  function verdictKey(p:string,a:string,c:OAuthCredentials){return `${p}\0${a}\0${credentialGeneration(c)}`;}
34
56
  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;}
35
57
 
@@ -195,7 +217,10 @@ async function resolveAccessSnapshotForAccount(
195
217
 
196
218
  const key = `${provider}\u0000${accountId}`;
197
219
  const existing = tokenRefreshes.get(key);
198
- if (existing) return existing;
220
+ if (existing) {
221
+ logOAuthEvent("OAuth refresh joined existing operation", { provider, accountId });
222
+ return existing;
223
+ }
199
224
 
200
225
  const refresh = (async (): Promise<OAuthAccessSnapshot> => {
201
226
  const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred);
@@ -349,6 +374,48 @@ export async function refreshAnthropicAccountWithLock(
349
374
  }
350
375
  }
351
376
 
377
+ export async function refreshGenericAccountWithLock(
378
+ provider: string,
379
+ accountId: string,
380
+ def: OAuthProviderDef,
381
+ callerCredential: OAuthCredentials,
382
+ deps: GenericRefreshDeps = {},
383
+ ): Promise<string> {
384
+ logOAuthEvent("OAuth refresh started", { provider, accountId });
385
+ const guard = await (deps.intentLock ?? createOAuthRefreshIntentLock(provider, accountId)).acquire();
386
+ try {
387
+ const stored = getAccountCredential(provider, accountId);
388
+ if (!stored) throw new OAuthLoginRequiredError(provider);
389
+ if (
390
+ credentialGeneration(stored) !== credentialGeneration(callerCredential)
391
+ && stored.expires > Date.now() + REFRESH_SKEW_MS
392
+ ) {
393
+ logOAuthEvent("OAuth refresh joined existing operation", { provider, accountId });
394
+ return stored.access;
395
+ }
396
+ const generation = credentialGeneration(stored);
397
+ try {
398
+ const fresh = merged(await def.refresh(stored.refresh), stored);
399
+ const outcome = await mergeAccountCredential(provider, accountId, fresh, {
400
+ expectedGeneration: generation,
401
+ afterPrePersistRead: deps.afterPrePersistRead,
402
+ });
403
+ if (outcome.superseded) {
404
+ if (outcome.stored.expires > Date.now() + REFRESH_SKEW_MS) return outcome.stored.access;
405
+ throw new OAuthLoginRequiredError(provider);
406
+ }
407
+ logOAuthEvent("OAuth credentials rotated and persisted", { provider, accountId });
408
+ return fresh.access;
409
+ } catch (error) {
410
+ if (!isTerminalRefreshError(error)) throw error;
411
+ await markAccountNeedsReauthIfGeneration(provider, accountId, generation);
412
+ throw new OAuthLoginRequiredError(provider);
413
+ }
414
+ } finally {
415
+ guard.release();
416
+ }
417
+ }
418
+
352
419
  async function refreshAndPersistAccessToken(
353
420
  provider: string,
354
421
  accountId: string,
@@ -368,23 +435,7 @@ async function refreshAndPersistAccessToken(
368
435
  if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred);
369
436
  if (provider === "anthropic") return refreshAnthropicAccountWithLock(provider, accountId, def, cred);
370
437
  try {
371
- const fresh = await def.refresh(cred.refresh);
372
- const detachedLocalCli = provider === "xai" && cred.source === "local-cli";
373
- if (detachedLocalCli) console.warn(XAI_LOCAL_CLI_DETACH_WARNING);
374
- // Persist to THIS account (rotation-safe: new refresh token hits disk before use) without
375
- // touching activeAccountId.
376
- await saveAccountCredential(provider, accountId, {
377
- ...fresh,
378
- source: detachedLocalCli ? "oauth" : fresh.source ?? cred.source ?? "oauth",
379
- // Preserve a previously-discovered project id when a refresh-time re-discovery comes back empty
380
- // (e.g. a transient network blip), so Antigravity does not lose its CCA project across refresh.
381
- ...(fresh.projectId === undefined && cred.projectId ? { projectId: cred.projectId } : {}),
382
- ...(fresh.apiBaseUrl === undefined && cred.apiBaseUrl ? { apiBaseUrl: cred.apiBaseUrl } : {}),
383
- // Preserve identity fields the refresh response may omit, so identity matching stays stable.
384
- ...(fresh.email === undefined && cred.email ? { email: cred.email } : {}),
385
- ...(fresh.accountId === undefined && cred.accountId ? { accountId: cred.accountId } : {}),
386
- });
387
- return fresh.access;
438
+ return await refreshGenericAccountWithLock(provider, accountId, def, cred);
388
439
  } catch (err) {
389
440
  if (provider === "kiro" && isActive) {
390
441
  const imported = readFreshKiroCliCredential();
@@ -393,10 +444,6 @@ async function refreshAndPersistAccessToken(
393
444
  return imported.access;
394
445
  }
395
446
  }
396
- if (isTerminalRefreshError(err)) {
397
- await markAccountNeedsReauth(provider, accountId, true);
398
- throw new OAuthLoginRequiredError(provider);
399
- }
400
447
  throw err;
401
448
  }
402
449
  }
package/src/oauth/kiro.ts CHANGED
@@ -80,15 +80,17 @@ export async function loginKiro(ctrl: OAuthController): Promise<OAuthCredentials
80
80
  url: "",
81
81
  instructions:
82
82
  "No kiro-cli token found. Paste a Kiro access token below (starts with 'aoa'). " +
83
- "Run `kiro-cli login` first, or set KIRO_ACCESS_TOKEN.",
83
+ "Otherwise install the Kiro CLI (`curl -fsSL https://cli.kiro.dev/install | bash`), " +
84
+ "run `kiro-cli login`, and retry — or set KIRO_ACCESS_TOKEN.",
84
85
  });
85
- ctrl.onProgress?.("No kiro-cli token found. Paste a Kiro access token (starts with 'aoa').");
86
+ ctrl.onProgress?.("No kiro-cli token found. Paste a Kiro access token (starts with 'aoa'), or install the Kiro CLI and run `kiro-cli login` first.");
86
87
  const raw = (await ctrl.onManualCodeInput()).trim();
87
88
  if (raw) return { access: raw, refresh: "", expires: Date.now() + 3600_000, source: "manual" };
88
89
  }
89
90
 
90
91
  throw new Error(
91
- "Kiro: no token found. Run `kiro-cli login` first (import), or set KIRO_ACCESS_TOKEN. " +
92
+ "Kiro: no token found. Install the Kiro CLI (`curl -fsSL https://cli.kiro.dev/install | bash`) " +
93
+ "and run `kiro-cli login` to import its session, or set KIRO_ACCESS_TOKEN. " +
92
94
  "Browser login is not supported for Kiro.",
93
95
  );
94
96
  }
@@ -0,0 +1,48 @@
1
+ // src/oauth/log.ts
2
+ import { maskAccountId } from "../lib/privacy";
3
+
4
+ /** Normalize camelCase / snake_case / kebab-case field names before secret checks. */
5
+ function normalizeFieldKey(key: string): string {
6
+ return key
7
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
8
+ .replace(/-/g, "_")
9
+ .toLowerCase();
10
+ }
11
+
12
+ const FORBIDDEN_NORMALIZED = new Set([
13
+ "access",
14
+ "refresh",
15
+ "authorization",
16
+ "code",
17
+ "token",
18
+ "access_token",
19
+ "refresh_token",
20
+ "id_token",
21
+ "client_secret",
22
+ "oauth_code",
23
+ "clientsecret",
24
+ ]);
25
+
26
+ function isForbiddenFieldKey(key: string): boolean {
27
+ const normalized = normalizeFieldKey(key);
28
+ if (FORBIDDEN_NORMALIZED.has(normalized)) return true;
29
+ if (normalized.endsWith("_token") || normalized.endsWith("_secret") || normalized.endsWith("_code")) {
30
+ return true;
31
+ }
32
+ return false;
33
+ }
34
+
35
+ export function logOAuthEvent(
36
+ event: string,
37
+ fields: { provider: string; accountId?: string; [key: string]: unknown },
38
+ ): void {
39
+ const parts = [`[opencodex] ${event}`, `provider=${fields.provider}`];
40
+ if (fields.accountId) parts.push(`account=${maskAccountId(fields.accountId)}`);
41
+ for (const [key, value] of Object.entries(fields)) {
42
+ if (key === "provider" || key === "accountId") continue;
43
+ if (isForbiddenFieldKey(key)) continue;
44
+ if (value === undefined) continue;
45
+ parts.push(`${key}=${String(value)}`);
46
+ }
47
+ console.info(parts.join(" "));
48
+ }
@@ -40,16 +40,41 @@ export function getAuthRefreshIntentPath(provider: string, accountId: string): s
40
40
  return `${getAuthRefreshIntentLockPath(provider, accountId)}.json`;
41
41
  }
42
42
  export interface OAuthRefreshIntent { version: 1; provider: string; accountId: string; generation: string; createdAt: number; uncertain?: true }
43
+ function parseOAuthRefreshIntent(
44
+ provider: string,
45
+ accountId: string,
46
+ raw: string,
47
+ ): OAuthRefreshIntent {
48
+ const value = JSON.parse(raw) as Partial<OAuthRefreshIntent>;
49
+ if (
50
+ value.version !== 1
51
+ || value.provider !== provider
52
+ || value.accountId !== accountId
53
+ || typeof value.generation !== "string"
54
+ || typeof value.createdAt !== "number"
55
+ ) {
56
+ return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true };
57
+ }
58
+ return value as OAuthRefreshIntent;
59
+ }
60
+
43
61
  export function readOAuthRefreshIntent(provider: string, accountId: string): OAuthRefreshIntent | undefined {
44
62
  const path = getAuthRefreshIntentPath(provider, accountId);
45
63
  try {
46
64
  hardenConfigDir();
47
65
  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;
66
+ return parseOAuthRefreshIntent(provider, accountId, readFileSync(path, "utf8"));
67
+ } catch (error) {
68
+ if (errorCode(error) === "ENOENT") return undefined;
69
+ return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true };
70
+ }
71
+ }
72
+
73
+ /** Observe-only intent read for diagnostics: no chmod/ACL hardening. */
74
+ export function peekOAuthRefreshIntent(provider: string, accountId: string): OAuthRefreshIntent | undefined {
75
+ const path = getAuthRefreshIntentPath(provider, accountId);
76
+ try {
77
+ return parseOAuthRefreshIntent(provider, accountId, readFileSync(path, "utf8"));
53
78
  } catch (error) {
54
79
  if (errorCode(error) === "ENOENT") return undefined;
55
80
  return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true };
@@ -89,6 +114,20 @@ export function loadAuthStore(): AuthStore {
89
114
  return loadAuthStoreInternal().store;
90
115
  }
91
116
 
117
+ /**
118
+ * Observe-only auth store read for diagnostics (`ocx doctor` / status).
119
+ * Does not chmod paths or backup invalid JSON — corrupt files are treated as empty.
120
+ */
121
+ export function peekAuthStore(): AuthStore {
122
+ const path = getAuthStorePath();
123
+ if (!existsSync(path)) return {};
124
+ try {
125
+ return normalizeAuthStore(JSON.parse(readFileSync(path, "utf-8"))).store;
126
+ } catch {
127
+ return {};
128
+ }
129
+ }
130
+
92
131
  function persist(store: AuthStore): void {
93
132
  const dir = getConfigDir();
94
133
  if (!existsSync(dir)) {
@@ -114,7 +153,17 @@ export function createOAuthFileLock(options: OAuthFileLockOptions): { acquire():
114
153
  return { async acquire() { hardenConfigDir(); if(!existsSync(getConfigDir())) mkdirSync(getConfigDir(),{recursive:true,mode:0o700}); const ownerId=randomUUID(),started=now(); for(;;){ let fd:number|undefined; try { fd=openSync(options.path,"wx",0o600); const bytes=`${JSON.stringify({version:1,ownerId,pid:process.pid,createdAt:now()})}\n`; write(fd,bytes); const fs=fstatSync(fd); closeSync(fd); fd=undefined; const owned=snapshot(options.path); if(owned.bytes!==bytes||!sameFd(owned,fs)) throw new OAuthFileLockError("OAuth lock changed during creation"); let released=false; return {ownerId,release(){if(released)return;released=true;try{const a=snapshot(options.path);if(!sameSnapshot(owned,a))return;options.beforeReleaseUnlink?.();const b=snapshot(options.path);if(sameSnapshot(owned,b))unlinkSync(options.path);}catch(e){if(errorCode(e)!=="ENOENT")console.warn(`[oauth] lock release failed: ${e instanceof Error?e.message:String(e)}`);}}}; } catch(e) { if(fd!==undefined){let fs;try{fs=fstatSync(fd);}catch{}try{closeSync(fd);}catch{}if(fs)try{const a=snapshot(options.path);if(sameFd(a,fs)){options.beforeFailedCreateUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b)&&sameFd(b,fs))unlinkSync(options.path);}}catch{}} if(errorCode(e)!=="EEXIST")throw e instanceof OAuthFileLockError?e:new OAuthFileLockError("Could not create OAuth file lock",{cause:e}); }
115
154
  try{const a=snapshot(options.path);let created=a.mtimeMs;try{const p=JSON.parse(a.bytes);if(typeof p.createdAt==="number")created=Math.max(created,p.createdAt);}catch{}if(now()-created>stale){options.beforeStaleUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b))unlinkSync(options.path);continue;}}catch(e){if(errorCode(e)==="ENOENT")continue;throw new OAuthFileLockError("Could not inspect OAuth file lock",{cause:e});} const elapsed=now()-started;if(elapsed>=wait)throw new OAuthFileLockError(`Timed out after ${wait}ms waiting for OAuth file lock`);await sleep(Math.min(wait-elapsed,min+Math.floor(random()*(max-min+1)))); } } };
116
155
  }
117
- export function createOAuthRefreshIntentLock(provider:string,accountId:string,overrides:Partial<OAuthFileLockOptions>={}) { return createOAuthFileLock({path:getAuthRefreshIntentLockPath(provider,accountId),staleAfterMs:120000,...overrides}); }
156
+ /** Wait long enough for slow IdP refreshes (e.g. Cursor 15s × 3 attempts) before timing out. */
157
+ export const OAUTH_REFRESH_LOCK_WAIT_MS = 60_000;
158
+
159
+ export function createOAuthRefreshIntentLock(provider:string,accountId:string,overrides:Partial<OAuthFileLockOptions>={}) {
160
+ return createOAuthFileLock({
161
+ path: getAuthRefreshIntentLockPath(provider, accountId),
162
+ staleAfterMs: 120000,
163
+ waitTimeoutMs: OAUTH_REFRESH_LOCK_WAIT_MS,
164
+ ...overrides,
165
+ });
166
+ }
118
167
 
119
168
  /**
120
169
  * One-time downgrade safety net: the first time we persist the NEW shape over a file that
@@ -0,0 +1,75 @@
1
+ import { copyFileSync, existsSync, linkSync, readFileSync, rmSync } from "node:fs";
2
+ import { getConfigPath } from "../config";
3
+
4
+ export interface AlibabaBackupIO {
5
+ exists: (path: string) => boolean;
6
+ read: (path: string) => Buffer;
7
+ copy: (source: string, destination: string) => void;
8
+ /** Publish with no-replace semantics: fails with EEXIST if the destination exists. */
9
+ publishNoReplace: (temp: string, destination: string) => void;
10
+ remove: (path: string) => void;
11
+ }
12
+
13
+ const DEFAULT_IO: AlibabaBackupIO = {
14
+ exists: existsSync,
15
+ read: path => readFileSync(path),
16
+ copy: (source, destination) => copyFileSync(source, destination),
17
+ publishNoReplace: linkSync,
18
+ remove: path => rmSync(path, { force: true }),
19
+ };
20
+
21
+ export class AlibabaBackupIntegrityError extends Error {}
22
+
23
+ /**
24
+ * Immutable pre-migration snapshot of `config.json`.
25
+ *
26
+ * Written to a temp file first and published by `link`, which fails with EEXIST
27
+ * rather than replacing. Exclusive *creation* alone is not enough: a crash
28
+ * mid-copy can leave a truncated destination that the next run would accept as a
29
+ * valid rollback point. Copy-verify-link means a published snapshot is always a
30
+ * complete one.
31
+ *
32
+ * An existing snapshot is NOT required to equal the current config. Demanding
33
+ * that has a false positive that would brick a working install: a run that
34
+ * created the snapshot and then aborted for an unrelated reason leaves a
35
+ * perfectly valid snapshot, the user then edits `config.json` legitimately, and
36
+ * every subsequent start would throw out of `startServer` and refuse to boot. A
37
+ * safety net must never become the thing that stops the product.
38
+ *
39
+ * So an existing snapshot is reused as-is — it is the earliest one, which is
40
+ * exactly what a rollback point should be. Integrity is carried by publication.
41
+ *
42
+ * Deliberately not built on `backupConfigBeforeOpenAiTierMigration`: that
43
+ * function's stale/rollback classification exists for a repeatable migration and
44
+ * would be wrong here.
45
+ */
46
+ export function backupConfigBeforeAlibabaRegionMigration(
47
+ configPath = getConfigPath(),
48
+ io: AlibabaBackupIO = DEFAULT_IO,
49
+ ): "absent" | "created" | "reused" {
50
+ if (!io.exists(configPath)) return "absent";
51
+ const backup = `${configPath}.pre-alibaba-region-v1.bak`;
52
+ // The earliest snapshot is the most valuable one: it predates every migration
53
+ // this config has been through.
54
+ if (io.exists(backup)) return "reused";
55
+
56
+ const source = io.read(configPath);
57
+ const temp = `${backup}.${process.pid}.tmp`;
58
+ try {
59
+ io.copy(configPath, temp);
60
+ // Verify before publishing: a short copy must never become the snapshot.
61
+ if (!io.read(temp).equals(source)) {
62
+ throw new AlibabaBackupIntegrityError(`failed to write a complete backup to ${temp}`);
63
+ }
64
+ io.publishNoReplace(temp, backup);
65
+ return "created";
66
+ } catch (error) {
67
+ // Another process published between our `exists` check and the link. It went
68
+ // through the same copy-verify-link sequence, so it is complete by
69
+ // construction — nothing else writes this filename.
70
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return "reused";
71
+ throw error;
72
+ } finally {
73
+ io.remove(temp);
74
+ }
75
+ }
@@ -0,0 +1,143 @@
1
+ import { ALIBABA_INTL_BASE_URL_CHOICES } from "./base-url-choices";
2
+ import { providerConfigSeed } from "./derive";
3
+ import { rewriteProviderReferences } from "./provider-id-rewrite";
4
+ import { PROVIDER_REGISTRY } from "./registry";
5
+ import type { OcxConfig, OcxProviderConfig } from "../types";
6
+
7
+ const BEIJING_ID = "alibaba-token-plan";
8
+ const INTL_ID = "alibaba-token-plan-intl";
9
+
10
+ /**
11
+ * Credentials and switches the user owns directly. Everything else on the moved
12
+ * row is registry-derived Beijing metadata (`ocx provider add` and the GUI persist
13
+ * it, and registry enrichment only fills absent fields) and must NOT travel to the
14
+ * international id — carrying it would leave the intl provider serving Singapore
15
+ * while advertising the six-model Beijing Personal Edition catalog.
16
+ *
17
+ * `baseUrl` is carried deliberately: the migration only fires for a URL that
18
+ * `ALIBABA_INTL_BASE_URL_CHOICES` recognises, so it is nonblank and
19
+ * placeholder-free and satisfies the override guard in the router.
20
+ * `liveModels` is carried because it is directly user-editable, so resetting it
21
+ * to the seed's `false` would silently undo a deliberate choice.
22
+ * `authMode` is NOT carried — both entries are key-auth and the seed's value is
23
+ * the one matching the destination's registry contract.
24
+ * `defaultModel` and `note` are user-editable too and are handled below.
25
+ */
26
+ const USER_OWNED_FIELDS = ["apiKey", "apiKeyPool", "disabled", "baseUrl", "allowPrivateNetwork", "liveModels"] as const;
27
+
28
+ export interface AlibabaRegionMigrationProjection {
29
+ config: OcxConfig;
30
+ changed: boolean;
31
+ warnings: string[];
32
+ }
33
+
34
+ const normalize = (url: string): string => url.trim().replace(/\/+$/, "").toLowerCase();
35
+
36
+ /** True when the saved URL is one the international entry actually serves. */
37
+ function isInternationalEndpoint(baseUrl: string): boolean {
38
+ return ALIBABA_INTL_BASE_URL_CHOICES.some(
39
+ choice => choice.baseUrl && normalize(choice.baseUrl) === normalize(baseUrl),
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Seed the destination from the international registry entry, then overlay only
45
+ * what the user owns. `providerConfigSeed` is the same function `ocx provider add`
46
+ * uses, so the migrated row is indistinguishable from one the user had created
47
+ * against the international provider directly.
48
+ */
49
+ function buildIntlRow(source: OcxProviderConfig): OcxProviderConfig {
50
+ const entry = PROVIDER_REGISTRY.find(e => e.id === INTL_ID);
51
+ // Fail fast rather than fabricate a row: a missing registry entry means the
52
+ // destination this migration targets no longer exists.
53
+ if (!entry) throw new Error(`registry entry "${INTL_ID}" not found; cannot migrate "${BEIJING_ID}"`);
54
+ const seeded = providerConfigSeed(entry);
55
+ const writable = seeded as unknown as Record<string, unknown>;
56
+ const readable = source as unknown as Record<string, unknown>;
57
+ for (const field of USER_OWNED_FIELDS) {
58
+ if (readable[field] !== undefined) writable[field] = readable[field];
59
+ }
60
+
61
+ const servable = new Set(entry.models ?? []);
62
+ // A user-chosen default survives only when the destination can actually serve
63
+ // it; otherwise the international default already on the seed stands.
64
+ if (source.defaultModel && servable.has(source.defaultModel)) seeded.defaultModel = source.defaultModel;
65
+ // Same rule for the allowlist, handled here where the destination catalog is
66
+ // known — never by the generic reference rewriter.
67
+ const selected = source.selectedModels?.filter(id => servable.has(id));
68
+ if (selected && selected.length > 0) seeded.selectedModels = selected;
69
+ else delete seeded.selectedModels;
70
+
71
+ // `providerConfigSeed` does not carry `note`, so seed the destination's registry
72
+ // note explicitly, then let a genuinely user-authored note win. Comparing
73
+ // against the SOURCE registry note is what distinguishes "the user typed this"
74
+ // from "the Beijing entry's stock note came along for the ride".
75
+ const beijingNote = PROVIDER_REGISTRY.find(e => e.id === BEIJING_ID)?.note;
76
+ if (entry.note) seeded.note = entry.note;
77
+ if (source.note && source.note !== beijingNote) seeded.note = source.note;
78
+
79
+ return seeded;
80
+ }
81
+
82
+ /**
83
+ * Repair a config whose `alibaba-token-plan` entry points at an international
84
+ * endpoint (issue #457). The Beijing entry is pinned to cn-beijing and does not
85
+ * take a baseUrl override by design, so such a config routes an international key
86
+ * to Beijing and fails with 401 on every request.
87
+ *
88
+ * The entry is MOVED to `alibaba-token-plan-intl` rather than the Beijing entry
89
+ * being taught to serve Singapore: the two ids carry different model lists,
90
+ * context windows, modality maps and dashboards, and a URL override cannot carry
91
+ * those contracts across. That override fix shipped once, was backed out twice,
92
+ * and was refused again when PR #459 closed.
93
+ *
94
+ * Refuses to act when the destination already holds a value — a provider row or a
95
+ * key the reference rewrite would land on — because merging two settings is a
96
+ * user decision, not a migration's.
97
+ */
98
+ export function projectAlibabaRegionMigration(config: OcxConfig): AlibabaRegionMigrationProjection {
99
+ const beijing = config.providers[BEIJING_ID];
100
+ const savedBaseUrl = typeof beijing?.baseUrl === "string" ? beijing.baseUrl : "";
101
+ if (!beijing || !savedBaseUrl || !isInternationalEndpoint(savedBaseUrl)) {
102
+ return { config, changed: false, warnings: [] };
103
+ }
104
+ if (config.providers[INTL_ID]) {
105
+ return {
106
+ config,
107
+ changed: false,
108
+ warnings: [
109
+ `provider "${BEIJING_ID}" is configured with an international endpoint, but "${INTL_ID}" `
110
+ + `already exists. Both were left untouched: merging two credential sets is not a decision `
111
+ + `this migration can make. Move the key to "${INTL_ID}" and delete the unused entry.`,
112
+ ],
113
+ };
114
+ }
115
+
116
+ const projected = structuredClone(config);
117
+ delete projected.providers[BEIJING_ID];
118
+ projected.providers[INTL_ID] = buildIntlRow(beijing);
119
+ const { changed: rewritten, collisions } = rewriteProviderReferences(projected, BEIJING_ID, INTL_ID);
120
+ // The rewriter is not transactional, so the clone is already partly rewritten
121
+ // here. Returning the ORIGINAL config discards it.
122
+ if (collisions.length > 0) {
123
+ return {
124
+ config,
125
+ changed: false,
126
+ warnings: [
127
+ `provider "${BEIJING_ID}" needs to move to "${INTL_ID}", but ${collisions.join(", ")} `
128
+ + `already hold values for the destination. Nothing was changed: choosing which value `
129
+ + `survives is not a decision this migration can make. Resolve those entries and restart.`,
130
+ ],
131
+ };
132
+ }
133
+
134
+ return {
135
+ config: projected,
136
+ changed: true,
137
+ warnings: [
138
+ `moved provider "${BEIJING_ID}" to "${INTL_ID}": its saved endpoint is the international one, `
139
+ + `which the Beijing entry cannot serve. ${rewritten} reference(s) were re-pointed; the `
140
+ + `international model catalog now applies.`,
141
+ ],
142
+ };
143
+ }
@@ -0,0 +1,36 @@
1
+ import { saveConfig } from "../config";
2
+ import { backupConfigBeforeAlibabaRegionMigration } from "./alibaba-region-backup";
3
+ import { projectAlibabaRegionMigration } from "./alibaba-region-migration";
4
+ import type { OcxConfig } from "../types";
5
+
6
+ export interface AlibabaRegionStartupDeps {
7
+ project: typeof projectAlibabaRegionMigration;
8
+ backup: () => void;
9
+ save: (config: OcxConfig) => void;
10
+ }
11
+
12
+ /**
13
+ * Run the #457 recovery migration at startup.
14
+ *
15
+ * Fail-closed: a backup failure throws, and nothing between here and
16
+ * `startServer` catches it, so the proxy does not start rather than rewriting
17
+ * credentials without a rollback point. That is the same posture the OpenAI tier
18
+ * migration takes.
19
+ */
20
+ export function runAlibabaRegionStartupMigration(
21
+ config: OcxConfig,
22
+ deps: AlibabaRegionStartupDeps = {
23
+ project: projectAlibabaRegionMigration,
24
+ backup: () => { backupConfigBeforeAlibabaRegionMigration(); },
25
+ save: saveConfig,
26
+ },
27
+ ): OcxConfig {
28
+ const projection = deps.project(config);
29
+ // Warnings are emitted even on a no-op: the collision case IS the warning.
30
+ for (const warning of projection.warnings) console.warn(`[alibaba-region-migration] ${warning}`);
31
+ if (!projection.changed) return projection.config;
32
+ // Strictly before the save: the snapshot must describe the config as it was.
33
+ deps.backup();
34
+ deps.save(projection.config);
35
+ return projection.config;
36
+ }
@@ -7,7 +7,7 @@
7
7
  * A provider with a legacy bare `apiKey` is seeded into a one-entry pool on first touch.
8
8
  */
9
9
  import { createHash } from "node:crypto";
10
- import { saveConfig } from "../config";
10
+ import { saveConfigPreservingClaudeCode } from "../config";
11
11
  import type { OcxConfig, OcxProviderConfig } from "../types";
12
12
 
13
13
  export interface ProviderApiKeyInfo {
@@ -87,7 +87,7 @@ export function addProviderApiKey(config: OcxConfig, name: string, key: string,
87
87
  pool.push({ id, key: trimmed, ...(label?.trim() ? { label: label.trim() } : {}), addedAt: Date.now() });
88
88
  }
89
89
  provider.apiKey = trimmed;
90
- saveConfig(config);
90
+ saveConfigPreservingClaudeCode(config);
91
91
  return { id };
92
92
  }
93
93
 
@@ -98,7 +98,7 @@ export function setActiveProviderApiKey(config: OcxConfig, name: string, id: str
98
98
  const entry = ensurePool(provider).find(e => e.id === id);
99
99
  if (!entry) return false;
100
100
  provider.apiKey = entry.key;
101
- saveConfig(config);
101
+ saveConfigPreservingClaudeCode(config);
102
102
  return true;
103
103
  }
104
104
 
@@ -110,7 +110,7 @@ export function setProviderApiKeyLabel(config: OcxConfig, name: string, id: stri
110
110
  if (!entry) return false;
111
111
  if (label) entry.label = label;
112
112
  else delete entry.label;
113
- saveConfig(config);
113
+ saveConfigPreservingClaudeCode(config);
114
114
  return true;
115
115
  }
116
116
 
@@ -128,6 +128,6 @@ export function removeProviderApiKey(config: OcxConfig, name: string, id: string
128
128
  else delete provider.apiKey;
129
129
  }
130
130
  if (provider.apiKeyPool.length === 0) delete provider.apiKeyPool;
131
- saveConfig(config);
131
+ saveConfigPreservingClaudeCode(config);
132
132
  return true;
133
133
  }
@@ -27,6 +27,7 @@ export interface DerivedKeyLoginProvider {
27
27
  noPenaltyModels?: string[];
28
28
  autoToolChoiceOnlyModels?: string[];
29
29
  preserveReasoningContentModels?: string[];
30
+ reasoningSplitModels?: string[];
30
31
  thinkingToggleModels?: string[];
31
32
  thinkingBudgetModels?: string[];
32
33
  escapeBuiltinToolNames?: boolean;
@@ -77,6 +78,23 @@ function cloneRecordOfArrays(input: Record<string, string[]>): Record<string, st
77
78
  return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, [...value]]));
78
79
  }
79
80
 
81
+ /**
82
+ * Fill registry defaults BENEATH the user's per-model entries.
83
+ *
84
+ * Capability maps are keyed per model, so an all-or-nothing fill lets a single
85
+ * customized model hide the registry's knowledge about every other model. That
86
+ * is how a partially customized `modelInputModalities` could leave a
87
+ * vision-capable model advertising no image support, which in turn collapses any
88
+ * combo containing it to text-only. Routing already merges these maps per key
89
+ * (`mergeRecordFill` in src/router.ts); catalog enrichment now matches.
90
+ */
91
+ function fillRecordOfArrays(
92
+ seed: Record<string, string[]>,
93
+ user: Record<string, string[]> | undefined,
94
+ ): Record<string, string[]> {
95
+ return { ...cloneRecordOfArrays(seed), ...(user ? cloneRecordOfArrays(user) : {}) };
96
+ }
97
+
80
98
  function cloneNestedRecord(input: Record<string, Record<string, string>>): Record<string, Record<string, string>> {
81
99
  return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, { ...value }]));
82
100
  }
@@ -113,6 +131,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
113
131
  ...(entry.parallelToolCalls !== undefined ? { parallelToolCalls: entry.parallelToolCalls } : {}),
114
132
  ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
115
133
  ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
134
+ ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}),
116
135
  ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}),
117
136
  ...(entry.thinkingBudgetModels ? { thinkingBudgetModels: [...entry.thinkingBudgetModels] } : {}),
118
137
  ...(entry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: entry.escapeBuiltinToolNames } : {}),
@@ -153,6 +172,7 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
153
172
  ...(entry.noPenaltyModels ? { noPenaltyModels: [...entry.noPenaltyModels] } : {}),
154
173
  ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
155
174
  ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
175
+ ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}),
156
176
  ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}),
157
177
  ...(entry.thinkingBudgetModels ? { thinkingBudgetModels: [...entry.thinkingBudgetModels] } : {}),
158
178
  ...(entry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: entry.escapeBuiltinToolNames } : {}),
@@ -208,7 +228,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
208
228
  if (prov.liveModels === undefined && seed.liveModels !== undefined) prov.liveModels = seed.liveModels;
209
229
  if (prov.contextWindow === undefined && seed.contextWindow !== undefined) prov.contextWindow = seed.contextWindow;
210
230
  if (!prov.modelContextWindows && seed.modelContextWindows) prov.modelContextWindows = { ...seed.modelContextWindows };
211
- if (!prov.modelInputModalities && seed.modelInputModalities) prov.modelInputModalities = cloneRecordOfArrays(seed.modelInputModalities);
231
+ if (seed.modelInputModalities) prov.modelInputModalities = fillRecordOfArrays(seed.modelInputModalities, prov.modelInputModalities);
212
232
  if (prov.defaultMaxOutputTokens === undefined && seed.defaultMaxOutputTokens !== undefined) prov.defaultMaxOutputTokens = seed.defaultMaxOutputTokens;
213
233
  if (!prov.modelMaxOutputTokens && seed.modelMaxOutputTokens) prov.modelMaxOutputTokens = { ...seed.modelMaxOutputTokens };
214
234
  if (!prov.reasoningEfforts && seed.reasoningEfforts) prov.reasoningEfforts = [...seed.reasoningEfforts];
@@ -224,6 +244,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
224
244
  if (prov.parallelToolCalls === undefined && seed.parallelToolCalls !== undefined) prov.parallelToolCalls = seed.parallelToolCalls;
225
245
  if (!prov.autoToolChoiceOnlyModels && seed.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...seed.autoToolChoiceOnlyModels];
226
246
  if (!prov.preserveReasoningContentModels && seed.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...seed.preserveReasoningContentModels];
247
+ if (!prov.reasoningSplitModels && seed.reasoningSplitModels) prov.reasoningSplitModels = [...seed.reasoningSplitModels];
227
248
  if (!prov.thinkingToggleModels && seed.thinkingToggleModels) prov.thinkingToggleModels = [...seed.thinkingToggleModels];
228
249
  if (!prov.thinkingBudgetModels && seed.thinkingBudgetModels) prov.thinkingBudgetModels = [...seed.thinkingBudgetModels];
229
250
  if (prov.escapeBuiltinToolNames === undefined && seed.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = seed.escapeBuiltinToolNames;