@bitkyc08/opencodex 2.36.0-preview.20260829 → 2.36.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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DO8liQVL.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
  155. package/gui/dist/assets/index-DX0RGj3e.js +0 -112
@@ -435,6 +435,18 @@ export type CodexRuntimeProcessCachePeek =
435
435
  let resolveCacheEpoch = 0;
436
436
  let resolveCache: ResolveCacheMemo | null = null;
437
437
 
438
+ /**
439
+ * Bumped whenever persisted runtime state is replaced or process authority is cleared.
440
+ *
441
+ * Consumers that memoize anything derived from `codex-runtime.json` — entitlement's client
442
+ * version, for one — read this instead of a timestamp, so a runtime switch invalidates them at
443
+ * the moment of the write rather than after a delay window. Exposed because the alternative was
444
+ * a time-based guess that could answer under the previous version after the file had changed.
445
+ */
446
+ export function codexRuntimeStateEpoch(): number {
447
+ return resolveCacheEpoch;
448
+ }
449
+
438
450
  function publishResolveCache(key: string, at: number, value: ResolveCodexRuntimeResult): void {
439
451
  const epoch = ++resolveCacheEpoch;
440
452
  resolveCache = {
@@ -39,6 +39,7 @@ import { routeModel, type RouteResult } from "../router";
39
39
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
40
40
  import { codexAccountNamespaceForModel } from "./account-namespace-match";
41
41
  import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
42
+ import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
42
43
  import {
43
44
  getUpstreamHostHealth,
44
45
  normalizeUpstreamHostCircuitThreshold,
@@ -276,31 +277,68 @@ export function isSubagentModelUnavailable(
276
277
  poolAccountPreview,
277
278
  candidateAccountUsabilityOptions?.modelEligibleAccountIds,
278
279
  );
279
- if (isModelHealthBlocked(model, config, resolvedAccountId, now)) return true;
280
- if (!isPoolCodexRoute(route)) return false;
281
-
282
- // Pool candidates need a usable account. Derive requirement from the resolved
283
- // route (canonical openai defaults to pool even when codexAccountMode is omitted).
284
- if (!resolvedAccountId) return true;
285
- if (isCodexAccountPaused(config, resolvedAccountId)) return true;
286
- if (!isCodexAccountUsable(config, resolvedAccountId, candidateAccountUsabilityOptions)) return true;
287
- if (route.codexAccountId !== undefined) {
288
- // An account-qualified route is pinned and cannot consume Pool's recovery-probe
289
- // escape hatch. Honor both account-wide and model-scoped cooldowns so fallback
290
- // advances instead of selecting a candidate that exact auth will reject.
291
- const quotaScope = codexQuotaScopeForModel(route.modelId);
292
- if (getCodexQuotaHealthSnapshot(resolvedAccountId, quotaScope, now) !== null) return true;
293
- } else {
294
- const quotaScope = codexQuotaScopeForModel(route.modelId);
295
- const cooldown = getCodexQuotaHealthSnapshot(resolvedAccountId, quotaScope, now);
296
- if (cooldown !== null) {
297
- const probeAvailable = cooldown.quotaScope
298
- ? canAcquireCodexQuotaScopeProbeLease(resolvedAccountId, cooldown.quotaScope, now)
299
- : canAcquireCodexQuotaProbeLease(resolvedAccountId, now);
300
- if (!probeAvailable) return true;
280
+ const accountUnavailable = (
281
+ candidateAccountId: string | null,
282
+ usabilityOptions: CodexAccountUsabilityOptions | undefined,
283
+ includeQuotaExhaustion: boolean,
284
+ ): boolean => {
285
+ if (isModelHealthBlocked(model, config, candidateAccountId, now)) return true;
286
+ if (!isPoolCodexRoute(route)) return false;
287
+
288
+ // Pool candidates need a usable account. Derive requirement from the resolved
289
+ // route (canonical openai defaults to pool even when codexAccountMode is omitted).
290
+ if (!candidateAccountId) return true;
291
+ if (isCodexAccountPaused(config, candidateAccountId)) return true;
292
+ if (!isCodexAccountUsable(config, candidateAccountId, usabilityOptions)) return true;
293
+ if (route.codexAccountId !== undefined) {
294
+ // An account-qualified route is pinned and cannot consume Pool's recovery-probe
295
+ // escape hatch. Honor both account-wide and model-scoped cooldowns so fallback
296
+ // advances instead of selecting a candidate that exact auth will reject.
297
+ const quotaScope = codexQuotaScopeForModel(route.modelId);
298
+ if (getCodexQuotaHealthSnapshot(candidateAccountId, quotaScope, now) !== null) return true;
299
+ } else {
300
+ const quotaScope = codexQuotaScopeForModel(route.modelId);
301
+ const cooldown = getCodexQuotaHealthSnapshot(candidateAccountId, quotaScope, now);
302
+ if (cooldown !== null) {
303
+ const probeAvailable = cooldown.quotaScope
304
+ ? canAcquireCodexQuotaScopeProbeLease(candidateAccountId, cooldown.quotaScope, now)
305
+ : canAcquireCodexQuotaProbeLease(candidateAccountId, now);
306
+ if (!probeAvailable) return true;
307
+ }
301
308
  }
302
- }
303
- return isNativeModelQuotaExhausted(model, config, resolvedAccountId, now);
309
+ if (
310
+ !includeQuotaExhaustion
311
+ || (
312
+ candidateAccountId === MAIN_CODEX_ACCOUNT_ID
313
+ && usabilityOptions?.nativeMainSelectionOnly === true
314
+ )
315
+ ) return false;
316
+ return isNativeModelQuotaExhausted(model, config, candidateAccountId, now);
317
+ };
318
+
319
+ // Prefer a genuinely usable entitled pool account. Preview can deliberately return
320
+ // the configured active account even when no selectable candidate exists, so a
321
+ // null/main-only check is not enough to detect the temporary-drain case.
322
+ if (!accountUnavailable(resolvedAccountId, candidateAccountUsabilityOptions, true)) return false;
323
+
324
+ // During a temporary native-main drain, entitlement discovery excludes main to
325
+ // preserve the credential fence. If no non-main candidate can serve an unqualified
326
+ // gated model, retain main only as a read-free sentinel: final auth owns the atomic
327
+ // claim and returns maintenance instead of letting a routed fallback bypass it.
328
+ const preserveDrainingMainCandidate = route.codexAccountId === undefined
329
+ && candidateAccountUsabilityOptions?.nativeMainSelectionOnly === true
330
+ && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId);
331
+ if (!preserveDrainingMainCandidate) return true;
332
+ const drainingMainUsabilityOptions: CodexAccountUsabilityOptions = {
333
+ ...candidateAccountUsabilityOptions,
334
+ modelEligibleAccountIds: new Set([
335
+ ...(modelEligibleAccountIds ?? []),
336
+ MAIN_CODEX_ACCOUNT_ID,
337
+ ]),
338
+ };
339
+ // Quota scoring main would lazily read the native credential/plan. Cached health,
340
+ // pause, reauth, and cooldown state are safe; defer physical scoring to final auth.
341
+ return accountUnavailable(MAIN_CODEX_ACCOUNT_ID, drainingMainUsabilityOptions, false);
304
342
  }
305
343
 
306
344
  export function selectAvailableSubagentModel(
@@ -36,27 +36,29 @@ const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i;
36
36
  * Hard budget for the Windows identity-lookup PowerShell child. These lookups
37
37
  * run at startup and on config writes; a hung child must fail the lookup
38
38
  * (recoverable — the caller refuses) rather than wedge the proxy indefinitely.
39
- */
40
- const WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS = 8_000;
41
-
42
- /**
43
- * The same budget, widened for a contended CI runner.
44
39
  *
45
- * 8s is a generous ceiling for `powershell.exe -Command` on a real desktop and is not one
46
- * on a GitHub Windows runner executing a quarter of this suite: the composed-acceptance
47
- * cases fail there with "Windows effective-account lookup timed out" while the child is
48
- * still starting. That is runner contention, not a hung lookup, and the budget exists to
49
- * bound the latter.
40
+ * 30s, and the same on a desktop as in CI. The split below used to give desktops
41
+ * 8s, on the theory that only a shared runner is contended enough to need more.
42
+ * A zh-CN Windows 10 host measured 3.2s for the SID expression and 4.6s for the
43
+ * `Add-Type` LocalAppData expression per spawn, with a bare
44
+ * `powershell.exe -NoProfile -Command exit` costing ~3s where a typical desktop
45
+ * pays ~150ms — so ordinary spawn jitter breached 8s intermittently and `ocx sync`
46
+ * failed with "Windows effective-account lookup timed out" (#2914).
47
+ *
48
+ * That is the same contention the CI branch was widened for, which is what makes
49
+ * the split vestigial rather than merely conservative: it encoded an assumption
50
+ * about WHERE contention happens, and the assumption was wrong. Antivirus
51
+ * real-time scanning, a loaded Task Scheduler, and cold PowerShell startup do not
52
+ * care whether the machine is a runner.
50
53
  *
51
- * Gated on `CI` alone. A user's machine keeps the 8s ceiling exactly as before, so the
52
- * recoverable-refusal contract this budget protects is unchanged where it matters.
54
+ * The contract this budget protects is unchanged: a genuinely hung child still
55
+ * fails the lookup and the caller still refuses rather than writing. Only the
56
+ * ceiling moved, and it moved for the case where the lookup would have succeeded.
53
57
  */
54
- const WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_CI_MS = 30_000;
58
+ const WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS = 30_000;
55
59
 
56
60
  function windowsIdentityLookupTimeoutMs(): number {
57
- return process.env.CI === "true"
58
- ? WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_CI_MS
59
- : WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS;
61
+ return WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS;
60
62
  }
61
63
 
62
64
  /**
@@ -435,6 +437,18 @@ function resolveWindowsRuntimeRoot(identity: Extract<UserIdentity, { platform: "
435
437
  }
436
438
  }
437
439
 
440
+ /**
441
+ * Resolve the canonical, effective-user runtime root shared by Codex lock domains.
442
+ *
443
+ * This is a directory, not a final lock or database path. Callers create their
444
+ * own named child so independent exclusion domains cannot self-contend.
445
+ */
446
+ export function resolveEffectiveUserRuntimeRoot(identity: UserIdentity): string {
447
+ return identity.platform === "posix"
448
+ ? resolvePosixRuntimeRoot(identity.uid)
449
+ : resolveWindowsRuntimeRoot(identity);
450
+ }
451
+
438
452
  /**
439
453
  * Windows path identity is case-insensitive; everywhere else it is exact.
440
454
  *
@@ -461,9 +475,7 @@ export const resolveCodexCoordinatorDatabasePath: ResolveCodexCoordinatorDatabas
461
475
  if (!isAbsolute(canonicalCodexHome)) {
462
476
  refuse("The canonical CODEX_HOME must be an absolute path.");
463
477
  }
464
- const root = identity.platform === "posix"
465
- ? resolvePosixRuntimeRoot(identity.uid)
466
- : resolveWindowsRuntimeRoot(identity);
478
+ const root = resolveEffectiveUserRuntimeRoot(identity);
467
479
  const locks = join(root, "native-write-locks");
468
480
  if (identity.platform === "posix") ensurePrivatePosixDirectory(locks, identity.uid);
469
481
  else {
@@ -495,9 +507,7 @@ export const resolveCodexCatalogSerializationDatabasePath:
495
507
  if (!isAbsolute(canonicalCodexHome)) {
496
508
  refuse("The canonical CODEX_HOME must be an absolute path.");
497
509
  }
498
- const root = identity.platform === "posix"
499
- ? resolvePosixRuntimeRoot(identity.uid)
500
- : resolveWindowsRuntimeRoot(identity);
510
+ const root = resolveEffectiveUserRuntimeRoot(identity);
501
511
  const locks = join(root, "catalog-write-locks");
502
512
  if (identity.platform === "posix") ensurePrivatePosixDirectory(locks, identity.uid);
503
513
  else {
@@ -534,9 +544,7 @@ export const resolveCodexHistorySerializationDatabasePath:
534
544
  if (!isAbsolute(canonicalStateDbPath)) {
535
545
  refuse("The canonical Codex state database must be an absolute path.");
536
546
  }
537
- const root = identity.platform === "posix"
538
- ? resolvePosixRuntimeRoot(identity.uid)
539
- : resolveWindowsRuntimeRoot(identity);
547
+ const root = resolveEffectiveUserRuntimeRoot(identity);
540
548
  const locks = join(root, "history-write-locks");
541
549
  if (identity.platform === "posix") ensurePrivatePosixDirectory(locks, identity.uid);
542
550
  else {
@@ -43,3 +43,4 @@ export {
43
43
  concreteComboRequestBody,
44
44
  resetComboEffortWarningStateForTests,
45
45
  } from "./request";
46
+ export { earliestQuotaResetAt, quotaResetRemainingMs } from "./reset-window";
@@ -0,0 +1,46 @@
1
+ import type { ProviderQuota } from "../providers/quota";
2
+
3
+ function collectResetCandidates(quota: ProviderQuota): number[] {
4
+ const candidates: number[] = [];
5
+ const push = (value: number | undefined): void => {
6
+ if (value !== undefined && Number.isFinite(value)) candidates.push(value);
7
+ };
8
+ push(quota.fiveHourResetAt);
9
+ push(quota.weeklyResetAt);
10
+ push(quota.monthlyResetAt);
11
+ if (quota.customWindows) {
12
+ for (const w of quota.customWindows) {
13
+ push(w.resetAt);
14
+ }
15
+ }
16
+ return candidates;
17
+ }
18
+
19
+ /**
20
+ * Earliest future reset timestamp from a cached provider quota snapshot,
21
+ * or null when no fresh quota data exists or all resets have elapsed.
22
+ */
23
+ export function earliestQuotaResetAt(
24
+ quota: ProviderQuota | null,
25
+ now: number,
26
+ ): number | null {
27
+ if (!quota) return null;
28
+ const future = collectResetCandidates(quota).filter(ts => ts > now);
29
+ if (future.length > 0) return Math.min(...future);
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Milliseconds until the soonest known quota-window reset.
35
+ * Returns Infinity when no quota data exists, quota is stale, or all known
36
+ * reset timestamps have elapsed. An elapsed reset is stale evidence — it
37
+ * does not prove the next request has fresh capacity.
38
+ */
39
+ export function quotaResetRemainingMs(
40
+ quota: ProviderQuota | null,
41
+ now: number,
42
+ ): number {
43
+ const nearest = earliestQuotaResetAt(quota, now);
44
+ if (nearest === null) return Number.POSITIVE_INFINITY;
45
+ return nearest - now;
46
+ }
@@ -1,5 +1,7 @@
1
1
  import type { OcxComboTarget, OcxConfig } from "../types";
2
+ import { getCachedProviderQuota } from "../providers/quota-routing-cache";
2
3
  import { coolComboTarget, isComboTargetInCooldown } from "./failover";
4
+ import { quotaResetRemainingMs } from "./reset-window";
3
5
  import { getCombo, resolveComboId, targetKey } from "./types";
4
6
  import type { NormalizedComboConfig } from "./types";
5
7
  import {
@@ -19,6 +21,7 @@ interface SelectionState {
19
21
  activeKey?: string;
20
22
  successes: number;
21
23
  currentWeights: Map<string, number>;
24
+ successfulUses: Map<string, number>;
22
25
  }
23
26
 
24
27
  const selectionState = new Map<string, SelectionState>();
@@ -82,6 +85,36 @@ function smoothWeightedIndex(
82
85
  return best;
83
86
  }
84
87
 
88
+ /**
89
+ * Select the eligible target whose earliest known quota reset is nearest.
90
+ *
91
+ * Only reads the last successfully cached provider-quota snapshot; it never
92
+ * triggers an upstream quota probe. When no target has fresh reset data,
93
+ * every remaining value is Infinity and configured order becomes the
94
+ * fallback. Targets with elapsed or stale reset timestamps are treated as
95
+ * unknown (Infinity).
96
+ */
97
+ function resetWindowIndex(
98
+ targets: Required<OcxComboTarget>[],
99
+ eligible: (target: Required<OcxComboTarget>) => boolean,
100
+ now = Date.now(),
101
+ ): number {
102
+ let selected = -1;
103
+ let smallestRemaining = Number.POSITIVE_INFINITY;
104
+ for (let index = 0; index < targets.length; index++) {
105
+ const target = targets[index]!;
106
+ if (!eligible(target)) continue;
107
+ const remaining = quotaResetRemainingMs(getCachedProviderQuota(target.provider, now), now);
108
+ // Strict comparison deliberately retains configured order for ties,
109
+ // including the no-snapshot fallback where every value is Infinity.
110
+ if (selected < 0 || remaining < smallestRemaining) {
111
+ selected = index;
112
+ smallestRemaining = remaining;
113
+ }
114
+ }
115
+ return selected;
116
+ }
117
+
85
118
  export function pickComboTarget(
86
119
  config: OcxConfig,
87
120
  comboId: string,
@@ -103,7 +136,7 @@ export function pickComboTarget(
103
136
  if (combo.strategy === "round-robin") {
104
137
  let state = selectionState.get(comboId);
105
138
  if (!state) {
106
- state = { successes: 0, currentWeights: new Map() };
139
+ state = { successes: 0, currentWeights: new Map(), successfulUses: new Map() };
107
140
  selectionState.set(comboId, state);
108
141
  }
109
142
  if (state.activeKey) {
@@ -120,6 +153,41 @@ export function pickComboTarget(
120
153
  state.successes = 0;
121
154
  }
122
155
  }
156
+ } else if (combo.strategy === "random") {
157
+ // Weighted random selection happens independently for every request.
158
+ const eligibleTargets = combo.targets
159
+ .map((target, index) => ({ target, index }))
160
+ .filter(({ target }) => eligible(target));
161
+ if (eligibleTargets.length > 0) {
162
+ const totalWeight = eligibleTargets.reduce((sum, entry) => sum + entry.target.weight, 0);
163
+ let random = Math.random() * totalWeight;
164
+ for (const entry of eligibleTargets) {
165
+ random -= entry.target.weight;
166
+ if (random <= 0) {
167
+ targetIndex = entry.index;
168
+ break;
169
+ }
170
+ }
171
+ if (targetIndex < 0) targetIndex = eligibleTargets[eligibleTargets.length - 1]!.index;
172
+ }
173
+ } else if (combo.strategy === "least-used") {
174
+ let state = selectionState.get(comboId);
175
+ if (!state) {
176
+ state = { successes: 0, currentWeights: new Map(), successfulUses: new Map() };
177
+ selectionState.set(comboId, state);
178
+ }
179
+ let fewestUses = Number.POSITIVE_INFINITY;
180
+ for (let index = 0; index < combo.targets.length; index++) {
181
+ const target = combo.targets[index]!;
182
+ if (!eligible(target)) continue;
183
+ const uses = state.successfulUses.get(targetKey(target)) ?? 0;
184
+ if (targetIndex < 0 || uses < fewestUses) {
185
+ targetIndex = index;
186
+ fewestUses = uses;
187
+ }
188
+ }
189
+ } else if (combo.strategy === "reset-window") {
190
+ targetIndex = resetWindowIndex(combo.targets, eligible);
123
191
  } else {
124
192
  targetIndex = combo.targets.findIndex(eligible);
125
193
  }
@@ -141,9 +209,18 @@ export function noteComboSuccess(
141
209
  target: Required<OcxComboTarget>,
142
210
  writerGeneration = captureConfigGeneration(),
143
211
  ): void {
144
- if (combo.strategy !== "round-robin") return;
145
212
  const key = targetKey(target);
146
213
  if (!mayCommitComboState(comboId, key, writerGeneration)) return;
214
+ if (combo.strategy === "least-used") {
215
+ let state = selectionState.get(comboId);
216
+ if (!state) {
217
+ state = { successes: 0, currentWeights: new Map(), successfulUses: new Map() };
218
+ selectionState.set(comboId, state);
219
+ }
220
+ state.successfulUses.set(key, (state.successfulUses.get(key) ?? 0) + 1);
221
+ return;
222
+ }
223
+ if (combo.strategy !== "round-robin") return;
147
224
  const state = selectionState.get(comboId);
148
225
  if (!state || state.activeKey !== key) return;
149
226
  state.successes += 1;
@@ -206,6 +283,11 @@ export function reconcileComboRotationState(context: GenerationContext): number
206
283
  state.currentWeights.delete(key);
207
284
  removed += 1;
208
285
  }
286
+ for (const key of state.successfulUses.keys()) {
287
+ if (context.comboTargets.has(comboTargetOwnerKey(comboId, key))) continue;
288
+ state.successfulUses.delete(key);
289
+ removed += 1;
290
+ }
209
291
  }
210
292
  liveComboTargets = new Set(context.comboTargets);
211
293
  lastReconciledGeneration = context.generation;
@@ -215,8 +215,11 @@ export function comboConfigIssues(
215
215
  }
216
216
  if (body.strategy !== undefined
217
217
  && body.strategy !== "failover"
218
- && body.strategy !== "round-robin") {
219
- issues.push({ path: ["strategy"], message: 'strategy must be "failover" or "round-robin"' });
218
+ && body.strategy !== "round-robin"
219
+ && body.strategy !== "random"
220
+ && body.strategy !== "least-used"
221
+ && body.strategy !== "reset-window") {
222
+ issues.push({ path: ["strategy"], message: 'strategy must be "failover", "round-robin", "random", "least-used", or "reset-window"' });
220
223
  }
221
224
  if (body.stickyLimit !== undefined
222
225
  && (typeof body.stickyLimit !== "number" || !Number.isInteger(body.stickyLimit)
@@ -1,6 +1,11 @@
1
1
  import {
2
2
  chmodSync,
3
+ closeSync,
4
+ constants,
5
+ fchmodSync,
6
+ fstatSync,
3
7
  lstatSync,
8
+ openSync,
4
9
  realpathSync,
5
10
  truncateSync,
6
11
  unlinkSync,
@@ -42,6 +47,12 @@ export interface AtomicWriteIO {
42
47
  unlink: (path: string) => void;
43
48
  }
44
49
 
50
+ export interface AtomicWriteHooks {
51
+ afterTempWrite?: (tempPath: string, targetPath: string) => void;
52
+ beforeRename?: (tempPath: string, targetPath: string) => void;
53
+ validateBeforeRename?: (targetPath: string) => void;
54
+ }
55
+
45
56
  export class AtomicWriteResidualTempError extends Error {
46
57
  constructor(readonly tempPath: string, readonly hardened = true, options?: ErrorOptions) {
47
58
  super(`Atomic config write left a ${hardened ? "hardened " : ""}zero-byte temporary file`, options);
@@ -92,52 +103,120 @@ function assertResolvedTargetAllowed(path: string, target: string): void {
92
103
  assertNotRealHomeUnderTest(dirname(target));
93
104
  }
94
105
 
95
- export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO = {
96
- write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
97
- harden: target => {
98
- try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
99
- if (process.platform === "win32") hardenSecretPath(target, { required: true, timeoutMemoKey: path });
100
- },
101
- rename: renameAtomicFile,
102
- truncate: target => truncateSync(target, 0),
103
- unlink: unlinkSync,
104
- }): void {
106
+ function assertPrivateTempDescriptor(path: string, descriptor: number): void {
107
+ const opened = fstatSync(descriptor);
108
+ const linked = lstatSync(path);
109
+ if (!opened.isFile() || !linked.isFile()
110
+ || opened.dev !== linked.dev || opened.ino !== linked.ino) {
111
+ throw new Error("atomic temporary file identity changed before write");
112
+ }
113
+ if (process.platform !== "win32" && (opened.mode & 0o777) !== 0o600) {
114
+ throw new Error("atomic temporary file permissions are not owner-only");
115
+ }
116
+ }
117
+
118
+ function writePrivateTempFile(
119
+ path: string,
120
+ content: string,
121
+ timeoutMemoKey: string,
122
+ onCreated: () => void,
123
+ ): void {
124
+ const descriptor = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
125
+ onCreated();
126
+ try {
127
+ if (process.platform === "win32") {
128
+ hardenSecretPath(path, { required: true, timeoutMemoKey });
129
+ } else {
130
+ fchmodSync(descriptor, 0o600);
131
+ }
132
+ assertPrivateTempDescriptor(path, descriptor);
133
+ writeFileSync(descriptor, content, { encoding: "utf-8" });
134
+ } finally {
135
+ closeSync(descriptor);
136
+ }
137
+ }
138
+
139
+ async function writePrivateTempFileAsync(
140
+ path: string,
141
+ content: string,
142
+ timeoutMemoKey: string,
143
+ onCreated: () => void,
144
+ ): Promise<void> {
145
+ const descriptor = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
146
+ onCreated();
147
+ try {
148
+ if (process.platform === "win32") {
149
+ await hardenSecretPathAsync(path, { required: true, timeoutMemoKey });
150
+ } else {
151
+ fchmodSync(descriptor, 0o600);
152
+ }
153
+ assertPrivateTempDescriptor(path, descriptor);
154
+ writeFileSync(descriptor, content, { encoding: "utf-8" });
155
+ } finally {
156
+ closeSync(descriptor);
157
+ }
158
+ }
159
+
160
+ export function atomicWriteFile(
161
+ path: string,
162
+ content: string,
163
+ io?: AtomicWriteIO,
164
+ hooks: AtomicWriteHooks = {},
165
+ ): void {
105
166
  recordOwnedConfigPath(getConfigDir(), path);
106
167
  const target = resolveWriteTarget(path);
107
168
  assertResolvedTargetAllowed(path, target);
108
169
  const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
109
170
  let hardened = false;
171
+ let ownsTemp = false;
172
+ const effective: AtomicWriteIO = io ?? {
173
+ write: (tempPath, value) => writePrivateTempFile(tempPath, value, path, () => { ownsTemp = true; }),
174
+ harden: tempPath => {
175
+ try { chmodSync(tempPath, 0o600); } catch { /* platform may ignore chmod */ }
176
+ if (process.platform === "win32") {
177
+ hardenSecretPath(tempPath, { required: true, timeoutMemoKey: path });
178
+ }
179
+ },
180
+ rename: renameAtomicFile,
181
+ truncate: tempPath => truncateSync(tempPath, 0),
182
+ unlink: unlinkSync,
183
+ };
110
184
  try {
111
- io.write(tmp, content);
112
- io.harden(tmp);
185
+ if (io) ownsTemp = true;
186
+ effective.write(tmp, content);
187
+ hooks.afterTempWrite?.(tmp, target);
188
+ effective.harden(tmp);
113
189
  hardened = true;
114
- io.rename(tmp, target);
190
+ hooks.beforeRename?.(tmp, target);
191
+ hooks.validateBeforeRename?.(target);
192
+ effective.rename(tmp, target);
115
193
  forgetEphemeralSecretPath(tmp);
116
194
  } catch (cause) {
195
+ if (!ownsTemp) throw cause;
117
196
  let scrubbed = false;
118
197
  try {
119
- io.truncate(tmp);
198
+ effective.truncate(tmp);
120
199
  scrubbed = true;
121
200
  } catch (error) {
122
201
  if (isMissingPathError(error)) scrubbed = true;
123
202
  else {
124
- try { io.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
203
+ try { effective.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
125
204
  }
126
205
  }
127
206
  let removed = false;
128
207
  try {
129
- io.unlink(tmp);
208
+ effective.unlink(tmp);
130
209
  removed = true;
131
210
  } catch (error) {
132
211
  if (isMissingPathError(error)) removed = true;
133
212
  else {
134
- try { io.unlink(tmp); removed = true; }
213
+ try { effective.unlink(tmp); removed = true; }
135
214
  catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
136
215
  }
137
216
  }
138
217
  if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause });
139
218
  if (!removed && !hardened) {
140
- try { io.harden(tmp); hardened = true; } catch { /* reported below */ }
219
+ try { effective.harden(tmp); hardened = true; } catch { /* reported below */ }
141
220
  }
142
221
  if (removed) forgetEphemeralSecretPath(tmp);
143
222
  if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
@@ -163,12 +242,13 @@ export async function atomicWriteFileAsync(
163
242
  io?: AtomicWriteAsyncIO,
164
243
  testSeam?: AtomicWriteAsyncTestSeam,
165
244
  ): Promise<void> {
245
+ let ownsTemp = false;
166
246
  const effective: AtomicWriteAsyncIO = io ?? {
167
- write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
168
- harden: async target => {
169
- try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
247
+ write: (tempPath, value) => writePrivateTempFileAsync(tempPath, value, path, () => { ownsTemp = true; }),
248
+ harden: async tempPath => {
249
+ try { chmodSync(tempPath, 0o600); } catch { /* platform may ignore chmod */ }
170
250
  if (process.platform === "win32") {
171
- await hardenSecretPathAsync(target, { required: true, timeoutMemoKey: path });
251
+ await hardenSecretPathAsync(tempPath, { required: true, timeoutMemoKey: path });
172
252
  }
173
253
  },
174
254
  rename: renameAtomicFileAsync,
@@ -180,6 +260,7 @@ export async function atomicWriteFileAsync(
180
260
  const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
181
261
  let hardened = false;
182
262
  try {
263
+ if (io) ownsTemp = true;
183
264
  await effective.write(tmp, content);
184
265
  await testSeam?.afterTempWrite?.(tmp);
185
266
  await effective.harden(tmp);
@@ -187,6 +268,7 @@ export async function atomicWriteFileAsync(
187
268
  await effective.rename(tmp, target);
188
269
  forgetEphemeralSecretPath(tmp);
189
270
  } catch (cause) {
271
+ if (!ownsTemp) throw cause;
190
272
  let scrubbed = false;
191
273
  try {
192
274
  await effective.truncate(tmp);
@@ -1,4 +1,5 @@
1
1
  import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
2
+ import { redactSecretString } from "../lib/redact";
2
3
  import { modelRecordValue } from "../reasoning-effort";
3
4
  import {
4
5
  isWirePinnedModel,
@@ -123,6 +124,16 @@ export function booleanRecordConfigError(value: unknown, field: string): string
123
124
  return null;
124
125
  }
125
126
 
127
+ /** Validate the management DTO boundary for the opt-in empty-tool-output annotation. */
128
+ export function providerEmptyToolOutputConfigError(name: string, provider: unknown): string | null {
129
+ const raw = provider as Record<string, unknown> | null | undefined;
130
+ const value = raw === null || raw === undefined ? undefined : raw.annotateEmptyToolOutputs;
131
+ if (value !== undefined && typeof value !== "boolean") {
132
+ return `provider ${JSON.stringify(redactSecretString(name))} annotateEmptyToolOutputs must be a boolean`;
133
+ }
134
+ return null;
135
+ }
136
+
126
137
  export function reasoningSummaryDeliveryRecordConfigError(
127
138
  value: unknown,
128
139
  supportsReasoningSummaries: unknown,