@bitkyc08/opencodex 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
@@ -21,10 +21,28 @@ import { join } from "node:path";
21
21
  import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret } from "../config";
22
22
  import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
23
23
  import { recordOwnedConfigPath } from "../lib/config-ownership";
24
+ import { MAX_PENDING_OAUTH_MUTATIONS } from "../lib/translator-budget";
25
+ import {
26
+ captureConfigGeneration,
27
+ type GenerationContext,
28
+ } from "../lib/state-store-sweeper";
24
29
  import { validateCopilotApiBaseUrl } from "./github-copilot";
25
30
  import type { OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types";
26
31
 
27
32
  type AuthStore = Record<string, ProviderAccountSet>;
33
+ let lastReconciledGeneration = 0;
34
+ let liveOAuthAccountKeys = new Set<string>();
35
+
36
+ function oauthAccountKey(provider: string, accountId: string): string {
37
+ return `${provider}\0${accountId}`;
38
+ }
39
+
40
+ export function reconcileOAuthReauthState(context: GenerationContext): number {
41
+ if (context.generation <= lastReconciledGeneration) return 0;
42
+ liveOAuthAccountKeys = new Set(context.oauthAccountKeys);
43
+ lastReconciledGeneration = context.generation;
44
+ return 0;
45
+ }
28
46
 
29
47
  /** Providers whose account set is pinned to a single slot (see module doc). */
30
48
  const SINGLE_SLOT_PROVIDERS = new Set(["chatgpt"]);
@@ -41,7 +59,7 @@ export function getAuthRefreshIntentLockPath(provider: string, accountId: string
41
59
  export function getAuthRefreshIntentPath(provider: string, accountId: string): string {
42
60
  return `${getAuthRefreshIntentLockPath(provider, accountId)}.json`;
43
61
  }
44
- export interface OAuthRefreshIntent { version: 1; provider: string; accountId: string; generation: string; createdAt: number; uncertain?: true }
62
+ export interface OAuthRefreshIntent { version: 1; provider: string; accountId: string; generation: string; createdAt: number; flightId?: string; staleOwner?: true; uncertain?: true }
45
63
  function parseOAuthRefreshIntent(
46
64
  provider: string,
47
65
  accountId: string,
@@ -54,6 +72,8 @@ function parseOAuthRefreshIntent(
54
72
  || value.accountId !== accountId
55
73
  || typeof value.generation !== "string"
56
74
  || typeof value.createdAt !== "number"
75
+ || (value.flightId !== undefined && typeof value.flightId !== "string")
76
+ || (value.staleOwner !== undefined && value.staleOwner !== true)
57
77
  ) {
58
78
  return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true };
59
79
  }
@@ -82,13 +102,19 @@ export function peekOAuthRefreshIntent(provider: string, accountId: string): OAu
82
102
  return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true };
83
103
  }
84
104
  }
85
- export function writeOAuthRefreshIntent(provider: string, accountId: string, generation: string, createdAt = Date.now()): void {
105
+ export function writeOAuthRefreshIntent(provider: string, accountId: string, generation: string, createdAt = Date.now(), flightId?: string): void {
86
106
  const dir = getConfigDir();
87
107
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
88
108
  hardenConfigDir();
89
- const intent: OAuthRefreshIntent = { version: 1, provider, accountId, generation, createdAt };
109
+ const intent: OAuthRefreshIntent = { version: 1, provider, accountId, generation, createdAt, ...(flightId ? { flightId } : {}) };
90
110
  atomicWriteFile(getAuthRefreshIntentPath(provider, accountId), `${JSON.stringify(intent)}\n`);
91
111
  }
112
+ export function markOAuthRefreshIntentStaleOwner(provider: string, accountId: string, generation: string, flightId: string): boolean {
113
+ const current = readOAuthRefreshIntent(provider, accountId);
114
+ if (current?.uncertain || current?.generation !== generation || current.flightId !== flightId) return false;
115
+ atomicWriteFile(getAuthRefreshIntentPath(provider, accountId), `${JSON.stringify({ ...current, staleOwner: true })}\n`);
116
+ return true;
117
+ }
92
118
  export function clearOAuthRefreshIntent(provider: string, accountId: string, generation: string): boolean {
93
119
  const current = readOAuthRefreshIntent(provider, accountId);
94
120
  if (!current || current.generation !== generation) return false;
@@ -294,15 +320,118 @@ function normalizeAuthStore(raw: unknown): { store: AuthStore; hadLegacy: boolea
294
320
  * a guardian refresh persisting a non-active account cannot roll back a concurrent
295
321
  * active-account switch (lost update). Cross-process races are accepted (single proxy).
296
322
  */
323
+ const OAUTH_MUTATION_WAIT_MS = 30_000;
324
+ const oauthMutationEncoder = new TextEncoder();
297
325
  let mutationTail: Promise<void> = Promise.resolve();
298
- function serializeMutation<T>(work:()=>Promise<T>):Promise<T>{const result=mutationTail.then(work,work);mutationTail=result.then(()=>undefined,()=>undefined);return result;}
299
- export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
326
+ let pendingMutations = 0;
327
+ let mutationCurrentBytes = 0;
328
+ let mutationHighWaterBytes = 0;
329
+ interface QueuedOAuthMutation {
330
+ started: boolean;
331
+ settled: boolean;
332
+ timeout?: ReturnType<typeof setTimeout>;
333
+ run(): Promise<void>;
334
+ }
335
+ const mutationWaiters: QueuedOAuthMutation[] = [];
336
+ let mutationRunning = false;
337
+
338
+ export class OAuthMutationBusyError extends Error {
339
+ readonly code = "oauth_mutation_busy";
340
+ constructor(message = "OAuth mutation queue is busy") {
341
+ super(message);
342
+ this.name = "OAuthMutationBusyError";
343
+ }
344
+ }
345
+
346
+ export function oauthMutationTailSnapshot(): { currentBytes: number; highWaterBytes: number; active: number } {
347
+ return { currentBytes: mutationCurrentBytes, highWaterBytes: mutationHighWaterBytes, active: pendingMutations };
348
+ }
349
+
350
+ function retainedClosureStringBytes(values: readonly unknown[]): number {
351
+ const visit = (value: unknown): number => {
352
+ if (typeof value === "string") return oauthMutationEncoder.encode(value).byteLength;
353
+ if (Array.isArray(value)) return value.reduce((sum, entry) => sum + visit(entry), 0);
354
+ if (value && typeof value === "object") {
355
+ return Object.entries(value as Record<string, unknown>)
356
+ .reduce((sum, [key, entry]) => sum + oauthMutationEncoder.encode(key).byteLength + visit(entry), 0);
357
+ }
358
+ return 0;
359
+ };
360
+ return values.reduce<number>((sum, value) => sum + visit(value), 0);
361
+ }
362
+
363
+ function drainOAuthMutations(): void {
364
+ if (mutationRunning) return;
365
+ const next = mutationWaiters.shift();
366
+ if (!next) return;
367
+ if (next.settled) {
368
+ drainOAuthMutations();
369
+ return;
370
+ }
371
+ next.started = true;
372
+ if (next.timeout) clearTimeout(next.timeout);
373
+ mutationRunning = true;
374
+ mutationTail = next.run().finally(() => {
375
+ mutationRunning = false;
376
+ drainOAuthMutations();
377
+ });
378
+ }
379
+
380
+ function serializeMutation<T>(work: () => Promise<T>, retainedValues: readonly unknown[], waitMs = OAUTH_MUTATION_WAIT_MS): Promise<T> {
381
+ if (pendingMutations >= MAX_PENDING_OAUTH_MUTATIONS) return Promise.reject(new OAuthMutationBusyError());
382
+ pendingMutations += 1;
383
+ const retainedBytes = retainedClosureStringBytes(retainedValues);
384
+ mutationCurrentBytes += retainedBytes;
385
+ mutationHighWaterBytes = Math.max(mutationHighWaterBytes, mutationCurrentBytes);
386
+
387
+ let resolveResult!: (value: T | PromiseLike<T>) => void;
388
+ let rejectResult!: (reason?: unknown) => void;
389
+ const result = new Promise<T>((resolve, reject) => {
390
+ resolveResult = resolve;
391
+ rejectResult = reject;
392
+ });
393
+ const entry: QueuedOAuthMutation = {
394
+ started: false,
395
+ settled: false,
396
+ async run() {
397
+ try {
398
+ resolveResult(await work());
399
+ } catch (error) {
400
+ rejectResult(error);
401
+ } finally {
402
+ release();
403
+ }
404
+ },
405
+ };
406
+ const release = () => {
407
+ if (entry.settled) return;
408
+ entry.settled = true;
409
+ pendingMutations -= 1;
410
+ mutationCurrentBytes = Math.max(0, mutationCurrentBytes - retainedBytes);
411
+ };
412
+ entry.timeout = setTimeout(() => {
413
+ if (entry.started || entry.settled) return;
414
+ const index = mutationWaiters.indexOf(entry);
415
+ if (index >= 0) mutationWaiters.splice(index, 1);
416
+ release();
417
+ rejectResult(new OAuthMutationBusyError("OAuth mutation queue wait timed out"));
418
+ }, waitMs);
419
+ // Only unref the long default wait. Short waitMs (tests) must stay ref'd:
420
+ // on Windows Bun under `bun test --isolate`, an unref'd timer can fail to
421
+ // fire while the head mutation holds an unresolved Promise, hanging the
422
+ // waiter forever (#827 / full admission queue hang on windows-latest).
423
+ if (waitMs >= OAUTH_MUTATION_WAIT_MS) entry.timeout.unref?.();
424
+ mutationWaiters.push(entry);
425
+ drainOAuthMutations();
426
+ return result;
427
+ }
428
+ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
300
429
  const { store, hadLegacy } = loadAuthStoreInternal();
301
430
  if (hadLegacy) backupLegacyOnce();
302
431
  const result = await fn(store);
303
432
  persist(store);
304
433
  return result;
305
- }finally{guard.release();}});
434
+ }finally{guard.release();}}, retainedValues, options?.waitMs);
306
435
  }
307
436
 
308
437
  /** The ACTIVE account's credential for a provider (what requests should use). */
@@ -366,7 +495,7 @@ export async function saveCredential(
366
495
  set.accounts.push({ id, credential: safe, addedAt: Date.now() });
367
496
  set.activeAccountId = id;
368
497
  }
369
- });
498
+ }, [provider, safe]);
370
499
  }
371
500
 
372
501
  /** Remove the ACTIVE account; remaining accounts promote the first one. */
@@ -380,7 +509,7 @@ export async function removeCredential(provider: string): Promise<void> {
380
509
  return;
381
510
  }
382
511
  set.activeAccountId = set.accounts[0]!.id;
383
- });
512
+ }, [provider]);
384
513
  }
385
514
 
386
515
  // ---------------------------------------------------------------------------
@@ -395,6 +524,17 @@ export function listAccounts(provider: string): ProviderAccount[] {
395
524
  return loadAuthStore()[provider]?.accounts ?? [];
396
525
  }
397
526
 
527
+ export function listLiveOAuthAccountKeys(
528
+ providerNames: ReadonlySet<string>,
529
+ ): ReadonlySet<string> {
530
+ const keys = new Set<string>();
531
+ for (const [provider, accountSet] of Object.entries(loadAuthStore())) {
532
+ if (!providerNames.has(provider)) continue;
533
+ for (const account of accountSet.accounts) keys.add(`${provider}\0${account.id}`);
534
+ }
535
+ return keys;
536
+ }
537
+
398
538
  export function getAccountCredential(provider: string, accountId: string): OAuthCredentials | null {
399
539
  return loadAuthStore()[provider]?.accounts.find(a => a.id === accountId)?.credential ?? null;
400
540
  }
@@ -408,7 +548,7 @@ export async function saveAccountCredential(provider: string, accountId: string,
408
548
  if (!account) return;
409
549
  account.credential = safe;
410
550
  delete account.needsReauth;
411
- });
551
+ }, [provider, accountId, safe]);
412
552
  }
413
553
 
414
554
  export async function setActiveAccount(provider: string, accountId: string): Promise<boolean> {
@@ -417,7 +557,7 @@ export async function setActiveAccount(provider: string, accountId: string): Pro
417
557
  if (!set || !set.accounts.some(a => a.id === accountId)) return false;
418
558
  set.activeAccountId = accountId;
419
559
  return true;
420
- });
560
+ }, [provider, accountId]);
421
561
  }
422
562
 
423
563
  export async function setAccountAlias(provider: string, accountId: string, alias: string | undefined): Promise<boolean> {
@@ -427,12 +567,12 @@ export async function setAccountAlias(provider: string, accountId: string, alias
427
567
  if (alias) account.alias = alias;
428
568
  else delete account.alias;
429
569
  return true;
430
- });
570
+ }, [provider, accountId, alias]);
431
571
  }
432
572
 
433
573
  /** Remove one account by id; active removal promotes the first remaining account. */
434
574
  export async function removeAccount(provider: string, accountId: string): Promise<boolean> {
435
- return await mutateStore(store => {
575
+ const removed = await mutateStore(store => {
436
576
  const set = store[provider];
437
577
  if (!set) return false;
438
578
  const before = set.accounts.length;
@@ -444,7 +584,8 @@ export async function removeAccount(provider: string, accountId: string): Promis
444
584
  }
445
585
  if (set.activeAccountId === accountId) set.activeAccountId = set.accounts[0]!.id;
446
586
  return true;
447
- });
587
+ }, [provider, accountId]);
588
+ return removed;
448
589
  }
449
590
 
450
591
  /** Replace or clear a provider account set (used for transactional Kiro add-account rollback). */
@@ -467,17 +608,23 @@ export async function replaceProviderAccountSet(
467
608
  ...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}),
468
609
  })),
469
610
  };
470
- });
611
+ }, [provider, set]);
471
612
  }
472
613
 
473
- export async function markAccountNeedsReauth(provider: string, accountId: string, needsReauth: boolean): Promise<void> {
614
+ export async function markAccountNeedsReauth(
615
+ provider: string,
616
+ accountId: string,
617
+ needsReauth: boolean,
618
+ writerGeneration = captureConfigGeneration(),
619
+ ): Promise<void> {
620
+ if (writerGeneration < lastReconciledGeneration && !liveOAuthAccountKeys.has(oauthAccountKey(provider, accountId))) return;
474
621
  await mutateStore(store => {
475
622
  const account = store[provider]?.accounts.find(a => a.id === accountId);
476
623
  if (!account) return;
477
624
  if (needsReauth) account.needsReauth = true;
478
625
  else delete account.needsReauth;
479
- });
626
+ }, [provider, accountId]);
480
627
  }
481
628
 
482
- export async function mergeAccountCredential(provider:string,accountId:string,credential:OAuthCredentials,opts:{expectedGeneration?:string;afterPrePersistRead?:()=>void|Promise<void>}={}):Promise<{superseded:false}|{superseded:true;stored:OAuthCredentials}>{const safe=normalizeCredential(credential);if(!safe)throw new Error("Refusing to persist invalid OAuth credential");return await mutateStore(async store=>{await opts.afterPrePersistRead?.();const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account)throw new Error(`OAuth account disappeared before persist: ${provider}`);if(opts.expectedGeneration!==undefined&&credentialGeneration(account.credential)!==opts.expectedGeneration)return{superseded:true,stored:account.credential};account.credential=safe;delete account.needsReauth;return{superseded:false};});}
483
- export async function markAccountNeedsReauthIfGeneration(provider:string,accountId:string,generation:string):Promise<boolean>{return await mutateStore(store=>{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||credentialGeneration(account.credential)!==generation)return false;account.needsReauth=true;return true;});}
629
+ export async function mergeAccountCredential(provider:string,accountId:string,credential:OAuthCredentials,opts:{expectedGeneration?:string;afterPrePersistRead?:()=>void|Promise<void>}={}):Promise<{superseded:false}|{superseded:true;stored:OAuthCredentials}>{const safe=normalizeCredential(credential);if(!safe)throw new Error("Refusing to persist invalid OAuth credential");return await mutateStore(async store=>{await opts.afterPrePersistRead?.();const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account)throw new Error(`OAuth account disappeared before persist: ${provider}`);if(opts.expectedGeneration!==undefined&&credentialGeneration(account.credential)!==opts.expectedGeneration)return{superseded:true,stored:account.credential};account.credential=safe;delete account.needsReauth;return{superseded:false};},[provider,accountId,safe,opts.expectedGeneration]);}
630
+ export async function markAccountNeedsReauthIfGeneration(provider:string,accountId:string,generation:string,writerGeneration=captureConfigGeneration()):Promise<boolean>{const key=oauthAccountKey(provider,accountId);if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;return await mutateStore(store=>{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||credentialGeneration(account.credential)!==generation)return false;if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;account.needsReauth=true;return true;},[provider,accountId,generation]);}
@@ -22,11 +22,14 @@ import {
22
22
  markCodexAccountValidationFailed,
23
23
  readCodexAccountRecord,
24
24
  TokenRefreshError,
25
+ CodexCredentialRefreshBusyError,
26
+ CodexCredentialRefreshStaleError,
25
27
  } from "../codex/account-store";
26
28
  import { codexWarmupFailureReason, warmCodexAccount } from "../codex/warmup";
27
29
  import { getMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
28
30
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
29
31
  import { providerCodexAccountMode } from "../providers/registry";
32
+ import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
30
33
 
31
34
  export interface TokenGuardianHandle {
32
35
  stop(): void;
@@ -58,6 +61,8 @@ interface BackoffEntry {
58
61
 
59
62
  // Module-scoped so backoff survives across sweeps within one process (keyed "oauth:<p>" / "codex:<id>").
60
63
  const backoff = new Map<string, BackoffEntry>();
64
+ let lastReconciledGeneration = 0;
65
+ let liveBackoffKeys = new Set<string>();
61
66
 
62
67
  /** Test hook: clear backoff state between cases. */
63
68
  export function __resetGuardianState(): void {
@@ -87,7 +92,15 @@ function inBackoff(key: string, nowMs: number): boolean {
87
92
  return entry !== undefined && entry.retryAfterMs > nowMs;
88
93
  }
89
94
 
90
- function recordFailure(key: string, nowMs: number, baseSeconds: number, maxSeconds: number, permanent: boolean): void {
95
+ function recordFailure(
96
+ key: string,
97
+ nowMs: number,
98
+ baseSeconds: number,
99
+ maxSeconds: number,
100
+ permanent: boolean,
101
+ writerGeneration = captureConfigGeneration(),
102
+ ): void {
103
+ if (writerGeneration < lastReconciledGeneration && !liveBackoffKeys.has(key)) return;
91
104
  const prev = backoff.get(key);
92
105
  const attempts = (prev?.attempts ?? 0) + 1;
93
106
  // Permanent failures (revoked/expired refresh token) wait the full ceiling — nothing but a
@@ -116,6 +129,7 @@ async function runWithConcurrency(tasks: Array<() => Promise<void>>, limit: numb
116
129
  * ids only, never tokens).
117
130
  */
118
131
  export async function guardianSweep(nowMs: number = Date.now()): Promise<GuardianSweepResult> {
132
+ const writerGeneration = captureConfigGeneration();
119
133
  const config: OcxConfig = loadConfig();
120
134
  const g = config.tokenGuardian;
121
135
  const result: GuardianSweepResult = { enabled: !!g?.enabled, refreshed: [], warmed: [], failed: [], skippedBackoff: [] };
@@ -143,7 +157,7 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
143
157
  // Terminal grant failures surface as OAuthLoginRequiredError (account marked
144
158
  // needsReauth by the resolver) — back off at the ceiling; transient errors backoff exponentially.
145
159
  const permanent = err instanceof OAuthLoginRequiredError;
146
- recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, permanent);
160
+ recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, permanent, writerGeneration);
147
161
  result.failed.push(key);
148
162
  }
149
163
  });
@@ -174,7 +188,7 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
174
188
  backoff.delete(key);
175
189
  result.warmed.push(key);
176
190
  } catch (err) {
177
- recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, false);
191
+ recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, false, writerGeneration);
178
192
  result.failed.push(key);
179
193
  }
180
194
  });
@@ -210,11 +224,16 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
210
224
  }
211
225
  backoff.delete(key);
212
226
  } catch (err) {
227
+ if (err instanceof CodexCredentialRefreshBusyError || err instanceof CodexCredentialRefreshStaleError) {
228
+ recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, false, writerGeneration);
229
+ result.skippedBackoff.push(key);
230
+ return;
231
+ }
213
232
  const permanent = err instanceof TokenRefreshError && (err.reason === "revoked" || err.reason === "expired");
214
233
  if (needsWarmup && !(err instanceof TokenRefreshError)) {
215
234
  markCodexAccountValidationFailed(id, codexWarmupFailureReason(err));
216
235
  }
217
- recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, permanent);
236
+ recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, permanent, writerGeneration);
218
237
  result.failed.push(key);
219
238
  }
220
239
  });
@@ -225,6 +244,26 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
225
244
  return result;
226
245
  }
227
246
 
247
+ export function reconcileGuardianBackoff(context: GenerationContext): number {
248
+ if (context.generation <= lastReconciledGeneration) return 0;
249
+ const valid = new Set<string>();
250
+ for (const id of context.codexAccountIds) valid.add(`codex:${id}`);
251
+ for (const key of context.oauthAccountKeys) {
252
+ const separator = key.indexOf("\0");
253
+ if (separator <= 0) continue;
254
+ valid.add(`oauth:${key.slice(0, separator)}:${key.slice(separator + 1)}`);
255
+ }
256
+ let removed = 0;
257
+ for (const key of backoff.keys()) {
258
+ if (valid.has(key)) continue;
259
+ backoff.delete(key);
260
+ removed += 1;
261
+ }
262
+ liveBackoffKeys = valid;
263
+ lastReconciledGeneration = context.generation;
264
+ return removed;
265
+ }
266
+
228
267
  /**
229
268
  * Start the background sweep loop. Returns a handle whose stop() clears the pending timer (in-flight
230
269
  * refreshes settle on their own). Schedules recursively so each interval gets fresh jitter. The loop
@@ -13,7 +13,8 @@ export interface KiroOAuthMetadata {
13
13
  export type OAuthCredentials = {
14
14
  refresh: string;
15
15
  access: string;
16
- expires: number; // epoch ms (already skew-adjusted by the provider flow)
16
+ /** Epoch ms after any small provider-specific early-refresh margin; the shared gate adds 1 minute. */
17
+ expires: number;
17
18
  email?: string;
18
19
  accountId?: string;
19
20
  source?: OAuthCredentialSource;
@@ -39,6 +39,16 @@ export const ALIBABA_INTL_BASE_URL_CHOICES: readonly ProviderBaseUrlChoice[] = [
39
39
  { id: "custom", label: "Custom" },
40
40
  ];
41
41
 
42
+ /** Alibaba Coding Plan endpoint presets (international default; China mainland selectable). */
43
+ export const ALIBABA_CODING_INTL_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1";
44
+ export const ALIBABA_CODING_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1";
45
+
46
+ export const ALIBABA_CODING_BASE_URL_CHOICES: readonly ProviderBaseUrlChoice[] = [
47
+ { id: "intl", label: "International", baseUrl: ALIBABA_CODING_INTL_BASE_URL },
48
+ { id: "china", label: "China", baseUrl: ALIBABA_CODING_CN_BASE_URL },
49
+ { id: "custom", label: "Custom" },
50
+ ];
51
+
42
52
  /** Match a saved baseUrl to a known choice id (`custom` when it does not match). */
43
53
  export function matchBaseUrlChoice(
44
54
  choices: readonly ProviderBaseUrlChoice[],
@@ -4,6 +4,7 @@ import { PROVIDER_REGISTRY, providerMatchesRegistryTransport, type ProviderRegis
4
4
  export interface DerivedKeyLoginProvider {
5
5
  label: string;
6
6
  baseUrl: string;
7
+ responsesPath?: string;
7
8
  adapter: string;
8
9
  apiKeyTransport?: OcxProviderConfig["apiKeyTransport"];
9
10
  dashboardUrl: string;
@@ -53,6 +54,7 @@ export interface DerivedProviderPreset {
53
54
  label: string;
54
55
  adapter: string;
55
56
  baseUrl: string;
57
+ responsesPath?: string;
56
58
  defaultModel?: string;
57
59
  auth: "oauth" | "forward" | "key" | "local";
58
60
  codexAccountMode?: CodexAccountMode;
@@ -105,6 +107,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
105
107
  adapter: entry.adapter,
106
108
  baseUrl: entry.baseUrl,
107
109
  ...(entry.apiKeyTransport !== undefined ? { apiKeyTransport: entry.apiKeyTransport } : {}),
110
+ ...(entry.responsesPath ? { responsesPath: entry.responsesPath } : {}),
108
111
  authMode: entry.authKind === "local" ? undefined : entry.authKind,
109
112
  ...(entry.codexAccountMode ? { codexAccountMode: entry.codexAccountMode } : {}),
110
113
  ...(entry.keyOptional !== undefined ? { keyOptional: entry.keyOptional } : {}),
@@ -132,6 +135,8 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
132
135
  ...(entry.noPenaltyModels ? { noPenaltyModels: [...entry.noPenaltyModels] } : {}),
133
136
  ...(entry.parallelToolCalls !== undefined ? { parallelToolCalls: entry.parallelToolCalls } : {}),
134
137
  ...(entry.promptCacheKey !== undefined ? { promptCacheKey: entry.promptCacheKey } : {}),
138
+ ...(entry.responsesPath !== undefined ? { responsesPath: entry.responsesPath } : {}),
139
+ ...(entry.statelessResponses !== undefined ? { statelessResponses: entry.statelessResponses } : {}),
135
140
  ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
136
141
  ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
137
142
  ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}),
@@ -152,6 +157,7 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
152
157
  out[entry.id] = {
153
158
  label: entry.label,
154
159
  baseUrl: entry.baseUrl,
160
+ ...(entry.responsesPath ? { responsesPath: entry.responsesPath } : {}),
155
161
  adapter: entry.adapter,
156
162
  ...(entry.apiKeyTransport !== undefined ? { apiKeyTransport: entry.apiKeyTransport } : {}),
157
163
  dashboardUrl: entry.dashboardUrl,
@@ -227,6 +233,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
227
233
  const seed = providerConfigSeed(entry);
228
234
  if (prov.apiKeyTransport === undefined && seed.apiKeyTransport !== undefined) prov.apiKeyTransport = seed.apiKeyTransport;
229
235
  if (!prov.defaultModel && seed.defaultModel) prov.defaultModel = seed.defaultModel;
236
+ if (prov.responsesPath === undefined && seed.responsesPath !== undefined) prov.responsesPath = seed.responsesPath;
230
237
  // Fill mode only when absent: an explicit persisted `direct` must never be overwritten.
231
238
  if (prov.codexAccountMode === undefined && seed.codexAccountMode !== undefined) prov.codexAccountMode = seed.codexAccountMode;
232
239
  if (!prov.models && seed.models) prov.models = [...seed.models];
@@ -248,6 +255,10 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
248
255
  if (!prov.noPenaltyModels && seed.noPenaltyModels) prov.noPenaltyModels = [...seed.noPenaltyModels];
249
256
  if (prov.parallelToolCalls === undefined && seed.parallelToolCalls !== undefined) prov.parallelToolCalls = seed.parallelToolCalls;
250
257
  if (prov.promptCacheKey === undefined && seed.promptCacheKey !== undefined) prov.promptCacheKey = seed.promptCacheKey;
258
+ // Fill-only: a hand-edited path must survive, and a config saved before the registry
259
+ // learned this route still gets backfilled.
260
+ if (prov.responsesPath === undefined && seed.responsesPath !== undefined) prov.responsesPath = seed.responsesPath;
261
+ if (prov.statelessResponses === undefined && seed.statelessResponses !== undefined) prov.statelessResponses = seed.statelessResponses;
251
262
  if (!prov.autoToolChoiceOnlyModels && seed.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...seed.autoToolChoiceOnlyModels];
252
263
  if (!prov.preserveReasoningContentModels && seed.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...seed.preserveReasoningContentModels];
253
264
  if (!prov.reasoningSplitModels && seed.reasoningSplitModels) prov.reasoningSplitModels = [...seed.reasoningSplitModels];
@@ -287,6 +298,7 @@ function entryToPreset(entry: ProviderRegistryEntry): DerivedProviderPreset {
287
298
  label: entry.label,
288
299
  adapter: entry.adapter,
289
300
  baseUrl: entry.baseUrl,
301
+ ...(entry.responsesPath ? { responsesPath: entry.responsesPath } : {}),
290
302
  auth: entry.authKind === "forward" ? "forward" : entry.authKind === "oauth" ? "oauth" : entry.authKind === "local" ? "local" : "key",
291
303
  ...(entry.codexAccountMode ? { codexAccountMode: entry.codexAccountMode } : {}),
292
304
  ...(entry.codexAccountMode ? { provider: providerConfigSeed(entry) } : {}),
@@ -18,7 +18,7 @@ export const FREE_PROVIDER_ACCESS_GROUPS = {
18
18
  ],
19
19
  "recurring-credit": ["bytez", "nous-research"],
20
20
  "signup-credit": [
21
- "agentrouter", "ai21", "baichuan", "deepinfra", "deepseek", "doubao", "fireworks", "freemodel-dev", "glm-cn",
21
+ "agentrouter", "ai21", "baichuan", "baseten", "deepinfra", "deepseek", "doubao", "fireworks", "freemodel-dev", "glm-cn",
22
22
  "hyperbolic", "longcat", "monsterapi", "nebius", "novita", "nscale", "nvidia", "predibase", "publicai", "qoder",
23
23
  "scaleway", "sensenova", "stepfun", "together", "vertex",
24
24
  ],
@@ -119,6 +119,9 @@ const CONNECTABLE: Record<string, ConnectableOverride> = {
119
119
  agentrouter: { baseUrl: "https://agentrouter.org", dashboardUrl: "https://agentrouter.org", adapter: "anthropic", authKind: "key", supportLevel: "experimental", verification: "primary", modelsUrl: "https://agentrouter.org/v1/models", lastVerified: LAST_VERIFIED, discovery: "live", liveModels: true },
120
120
  ai21: openAi("https://api.ai21.com/studio/v1", "https://studio.ai21.com/account/api-key", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.ai21.com/reference/models" }),
121
121
  baichuan: openAi("https://api.baichuan-ai.com/v1", "https://platform.baichuan-ai.com/console/apikey", { verification: "official" }),
122
+ // Verified end-to-end 2026-07-30: /v1/models returns the OpenAI-shaped live catalog (13 models),
123
+ // and a chat completion against moonshotai/Kimi-K3 returned a standard chat.completion payload.
124
+ baseten: openAi("https://inference.baseten.co/v1", "https://app.baseten.co/settings/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.baseten.co/inference/model-apis/overview", modelsUrl: "https://inference.baseten.co/v1/models", lastVerified: "2026-07-30" }),
122
125
  deepinfra: openAi("https://api.deepinfra.com/v1/openai", "https://deepinfra.com/dash/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://deepinfra.com/docs/openai_api" }),
123
126
  deepseek: openAi("https://api.deepseek.com", "https://platform.deepseek.com/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://api-docs.deepseek.com/api/list-models" }),
124
127
  doubao: openAi("https://ark.cn-beijing.volces.com/api/v3", "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey", { verification: "official" }),
@@ -11,6 +11,7 @@
11
11
  import { saveConfigPreservingClaudeCode } from "../config";
12
12
  import type { OcxConfig, OcxProviderConfig } from "../types";
13
13
  import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport";
14
+ import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
14
15
 
15
16
  // ---- cooldown state (in-memory, same as codex/routing.ts) ----
16
17
 
@@ -99,6 +100,7 @@ export function rotateKeyOn429(
99
100
  keyCooldowns.set(cooldownKey(providerName, currentEntry.id), {
100
101
  cooldownUntil: now + cooldownMs,
101
102
  });
103
+ sweepExpiredOnWrite(now);
102
104
  }
103
105
 
104
106
  // Lost the race: someone already rotated away from the failed key. If the live key is healthy,
@@ -131,6 +133,16 @@ export function rotateKeyOn429(
131
133
  return null;
132
134
  }
133
135
 
136
+ export function sweepExpiredApiKeyCooldowns(now = Date.now()): number {
137
+ let removed = 0;
138
+ for (const [key, cooldown] of keyCooldowns) {
139
+ if (cooldown.cooldownUntil > now) continue;
140
+ keyCooldowns.delete(key);
141
+ removed += 1;
142
+ }
143
+ return removed;
144
+ }
145
+
134
146
  interface RotateProviderTransportOptions {
135
147
  retryAfter?: string | null;
136
148
  now?: number;
@@ -101,7 +101,10 @@ export async function resolveFirstUsableOpenAiSidecar(
101
101
  config,
102
102
  authContext.accountId,
103
103
  outcome,
104
- { probeLeaseId: authContext.probeLeaseId },
104
+ {
105
+ probeLeaseId: authContext.probeLeaseId,
106
+ writerGeneration: authContext.writerGeneration,
107
+ },
105
108
  ),
106
109
  }
107
110
  : {}),