@bitkyc08/opencodex 2.7.42 → 2.7.43

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 (143) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +13 -14
  3. package/gui/dist/assets/index-Czw-jpTU.css +1 -0
  4. package/gui/dist/assets/index-cmds12BG.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  10. package/src/adapters/cursor/protobuf-events.ts +24 -2
  11. package/src/adapters/cursor/protobuf-request.ts +1 -2
  12. package/src/adapters/cursor/tool-definitions.ts +68 -29
  13. package/src/adapters/google-wire-compiler.ts +4 -0
  14. package/src/adapters/google.ts +128 -2
  15. package/src/adapters/identity.ts +12 -2
  16. package/src/adapters/kiro.ts +49 -6
  17. package/src/adapters/mimo-free.ts +2 -0
  18. package/src/adapters/openai-responses.ts +246 -59
  19. package/src/claude/agents-inject.ts +5 -0
  20. package/src/claude/inbound.ts +26 -9
  21. package/src/cli/account-auth.ts +1 -1
  22. package/src/cli/agent-driven.ts +37 -0
  23. package/src/cli/claude.ts +3 -3
  24. package/src/cli/doctor.ts +23 -18
  25. package/src/cli/help.ts +42 -6
  26. package/src/cli/index.ts +88 -19
  27. package/src/cli/interactive-confirm.ts +129 -0
  28. package/src/cli/opencode.ts +701 -0
  29. package/src/cli/provider-runtime.ts +3 -0
  30. package/src/cli/provider.ts +31 -10
  31. package/src/cli/star-prompt.ts +55 -16
  32. package/src/cli/status.ts +47 -13
  33. package/src/codex/account-id.ts +34 -0
  34. package/src/codex/account-lifecycle.ts +4 -1
  35. package/src/codex/account-namespace-match.ts +63 -0
  36. package/src/codex/account-namespaces.ts +149 -0
  37. package/src/codex/account-pause.ts +20 -0
  38. package/src/codex/account-usability.ts +6 -1
  39. package/src/codex/app-server-processes.ts +511 -0
  40. package/src/codex/auth-api.ts +293 -34
  41. package/src/codex/auth-collision.ts +2 -1
  42. package/src/codex/auth-context.ts +60 -17
  43. package/src/codex/catalog/parsing.ts +16 -1
  44. package/src/codex/catalog/provider-fetch.ts +48 -12
  45. package/src/codex/catalog/sync.ts +18 -3
  46. package/src/codex/catalog.ts +1 -1
  47. package/src/codex/history-provider.ts +145 -1
  48. package/src/codex/inject.ts +114 -14
  49. package/src/codex/main-account.ts +2 -8
  50. package/src/codex/pool-rotation.ts +186 -0
  51. package/src/codex/quota.ts +15 -0
  52. package/src/codex/routing.ts +695 -106
  53. package/src/codex/shim.ts +4 -1
  54. package/src/codex/subagent-defaults.ts +550 -0
  55. package/src/codex/subagent-model-fallback.ts +2 -0
  56. package/src/codex/sync.ts +3 -0
  57. package/src/config.ts +566 -25
  58. package/src/images/artifacts.ts +516 -0
  59. package/src/images/fulfill-video.ts +163 -0
  60. package/src/images/fulfill.ts +111 -0
  61. package/src/images/index.ts +4 -0
  62. package/src/images/loop.ts +789 -0
  63. package/src/images/plan.ts +133 -0
  64. package/src/images/synthetic-tool.ts +133 -0
  65. package/src/images/types.ts +41 -0
  66. package/src/images/xai-client.ts +141 -0
  67. package/src/images/xai-video-client.ts +163 -0
  68. package/src/lib/admin-secrets.ts +25 -0
  69. package/src/lib/config-ownership.ts +327 -0
  70. package/src/lib/crash-guard.ts +2 -0
  71. package/src/lib/destination-policy.ts +122 -4
  72. package/src/lib/pinned-http.ts +151 -0
  73. package/src/lib/process-control.ts +2 -2
  74. package/src/lib/provider-outbound.ts +164 -0
  75. package/src/lib/provider-url.ts +14 -0
  76. package/src/lib/proxy-env.ts +18 -0
  77. package/src/lib/windows-elevation.ts +81 -3
  78. package/src/lib/windows-secret-acl.ts +189 -12
  79. package/src/lib/winsw.ts +2 -0
  80. package/src/oauth/anthropic-routing.ts +570 -0
  81. package/src/oauth/health.ts +6 -0
  82. package/src/oauth/index.ts +283 -72
  83. package/src/oauth/key-providers.ts +17 -6
  84. package/src/oauth/kimi.ts +2 -0
  85. package/src/oauth/kiro-credentials.ts +322 -10
  86. package/src/oauth/kiro.ts +423 -43
  87. package/src/oauth/login-cli.ts +32 -5
  88. package/src/oauth/store.ts +54 -4
  89. package/src/oauth/types.ts +11 -0
  90. package/src/providers/alibaba-region-migration.ts +16 -3
  91. package/src/providers/antigravity-models.ts +3 -0
  92. package/src/providers/api-keys.ts +13 -6
  93. package/src/providers/derive.ts +6 -0
  94. package/src/providers/key-failover.ts +24 -4
  95. package/src/providers/quota.ts +233 -29
  96. package/src/providers/registry.ts +11 -3
  97. package/src/responses/parser.ts +11 -0
  98. package/src/responses/state.ts +22 -8
  99. package/src/responses/tool-groups.ts +19 -0
  100. package/src/router.ts +14 -4
  101. package/src/server/auth-cors.ts +110 -22
  102. package/src/server/claude-messages.ts +8 -1
  103. package/src/server/gui-static.ts +30 -6
  104. package/src/server/images.ts +303 -9
  105. package/src/server/index.ts +77 -9
  106. package/src/server/lifecycle.ts +25 -1
  107. package/src/server/management/agent-settings-routes.ts +28 -4
  108. package/src/server/management/combo-routes.ts +7 -0
  109. package/src/server/management/config-routes.ts +5 -2
  110. package/src/server/management/logs-usage-routes.ts +167 -3
  111. package/src/server/management/oauth-account-routes.ts +163 -17
  112. package/src/server/management/provider-routes.ts +35 -7
  113. package/src/server/management/system-restart.ts +172 -0
  114. package/src/server/management/system-routes.ts +33 -10
  115. package/src/server/management-api.ts +2 -2
  116. package/src/server/management-auth.ts +216 -0
  117. package/src/server/proxy-liveness.ts +14 -3
  118. package/src/server/responses/compact.ts +21 -13
  119. package/src/server/responses/core.ts +583 -152
  120. package/src/server/responses-image-gen-repair.ts +118 -0
  121. package/src/server/responses-item-id-repair.ts +10 -85
  122. package/src/server/sse-payload-rewrite.ts +116 -0
  123. package/src/server/system-env.ts +28 -10
  124. package/src/service.ts +49 -2
  125. package/src/storage/cleanup-job.ts +57 -0
  126. package/src/storage/cleanup.ts +1504 -28
  127. package/src/storage/policy-job.ts +366 -0
  128. package/src/storage/policy-scheduler.ts +40 -0
  129. package/src/storage/policy-worker.ts +53 -0
  130. package/src/storage/policy.ts +522 -0
  131. package/src/storage/restore-job.ts +242 -0
  132. package/src/storage/restore-worker.ts +52 -0
  133. package/src/storage/storage-mutation-coordinator.ts +109 -0
  134. package/src/tray/windows.ts +2 -0
  135. package/src/types.ts +96 -1
  136. package/src/update/index.ts +36 -18
  137. package/src/update/job.ts +103 -12
  138. package/src/update/npm-invocation.d.mts +23 -0
  139. package/src/update/npm-invocation.mjs +94 -0
  140. package/src/usage/debug.ts +2 -0
  141. package/src/usage/log.ts +4 -0
  142. package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
  143. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
- import { atomicWriteFile, loadConfig, websocketsEnabled } from "../config";
2
+ import { atomicWriteFile, loadConfig, subagentDefaultSyncEffective, websocketsEnabled } from "../config";
3
3
  import { markJournalInjectedState, removeJournal, restoreJournalState, writeJournal } from "./journal";
4
4
  import { restoreCodexCatalog } from "./catalog";
5
5
  import { migrateHistoryToOpenai, syncCodexHistoryProvider } from "./history-provider";
@@ -15,6 +15,10 @@ import {
15
15
  } from "./injected-marker";
16
16
  import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, resolveCodexConfigPath, tomlString } from "./paths";
17
17
  import { resolveEffectiveProjectModelProvider } from "./project-config-warnings";
18
+ import {
19
+ transformManagedSubagentDefaults,
20
+ type ManagedSubagentDefaults,
21
+ } from "./subagent-defaults";
18
22
  import type { OcxConfig } from "../types";
19
23
 
20
24
  // Ownership predicates live in `./injected-marker` so `journal.ts` can reach them
@@ -68,6 +72,16 @@ export interface InjectCodexOptions {
68
72
  catalogPath?: string | null;
69
73
  }
70
74
 
75
+ function configuredManagedSubagentDefaults(
76
+ config: Pick<OcxConfig, "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults"> | undefined,
77
+ ): ManagedSubagentDefaults | null {
78
+ if (!subagentDefaultSyncEffective(config ?? {})) return null;
79
+ return {
80
+ model: config!.injectionModel!.trim(),
81
+ ...(config!.injectionEffort?.trim() ? { reasoningEffort: config!.injectionEffort.trim() } : {}),
82
+ };
83
+ }
84
+
71
85
  /**
72
86
  * The `[model_providers.opencodex]` TABLE only. A table is position-independent in TOML, so it is
73
87
  * safe to append at EOF. The bare root key `model_provider = "opencodex"` is NOT included here —
@@ -458,7 +472,13 @@ export function chooseCatalogPathForInjection(content: string, requested?: strin
458
472
  return existsSync(DEFAULT_CATALOG_PATH) ? DEFAULT_CATALOG_PATH : null;
459
473
  }
460
474
 
461
- export async function injectCodexConfig(port: number, config?: OcxConfig, options: InjectCodexOptions = {}): Promise<{ success: boolean; message: string }> {
475
+ export interface CodexInjectResult {
476
+ success: boolean;
477
+ message: string;
478
+ nativeSubagentDefaultsWarning?: string;
479
+ }
480
+
481
+ export async function injectCodexConfig(port: number, config?: OcxConfig, options: InjectCodexOptions = {}): Promise<CodexInjectResult> {
462
482
  if (!existsSync(CODEX_CONFIG_PATH)) {
463
483
  return { success: false, message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?` };
464
484
  }
@@ -469,8 +489,12 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
469
489
  // A launcher may have journaled before the provider manager took ownership. Never let shutdown
470
490
  // replay that stale snapshot over externally managed config.
471
491
  removeJournal();
492
+ const nativeSubagentDefaultsWarning = configuredManagedSubagentDefaults(config)
493
+ ? `Native Codex sub-agent defaults were not injected: external model_provider ${tomlString(activeProvider)} owns config.toml.`
494
+ : undefined;
472
495
  return {
473
496
  success: true,
497
+ ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}),
474
498
  message: `⚠️ Codex routing NOT injected: config.toml selects the external model_provider ${tomlString(activeProvider)}.\n` +
475
499
  ` OpenCodex preserves external provider configuration so existing ${tomlString(activeProvider)} session history stays visible.\n` +
476
500
  ` Configure that provider for Responses passthrough at http://${providerBaseHost(config?.hostname)}:${port}/v1` +
@@ -479,16 +503,31 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
479
503
  };
480
504
  }
481
505
 
506
+ // Marker-owned native defaults are OpenCodex residue, never part of the
507
+ // user's journal baseline. Clean them before either snapshotting or adding a
508
+ // root routing key: inserting that key ahead of a marker-owned first table
509
+ // would otherwise separate the table marker from its header. Ambiguous
510
+ // markers fail closed without writing config, profile, or journal state.
511
+ const nativeDefaultsBaseline = transformManagedSubagentDefaults(rawContent, null);
512
+ if (!nativeDefaultsBaseline.ok) {
513
+ return {
514
+ success: false,
515
+ message: `Codex config injection refused: existing OpenCodex-managed native sub-agent defaults are ambiguous: ${nativeDefaultsBaseline.error}. `
516
+ + `No files were changed; inspect ${CODEX_CONFIG_PATH}.`,
517
+ };
518
+ }
519
+ const baselineContent = nativeDefaultsBaseline.content;
520
+
482
521
  // Classify and journal the same bytes: a native config is a valid original and
483
522
  // supersedes a stale snapshot (#477), while an injected one must never become
484
523
  // one — that is how opencodex routing would survive `ocx stop`.
485
524
  writeJournal({
486
525
  currentStateIsNative: !hasInjectedCodexRouting(rawContent),
487
- configContent: rawContent,
526
+ configContent: baselineContent,
488
527
  });
489
528
  // EOL boundary: transforms below are LF-pure; preserve the file's dominant ending on write.
490
529
  const eol = dominantEol(rawContent);
491
- let content = applyEol(rawContent, "\n");
530
+ let content = applyEol(baselineContent, "\n");
492
531
 
493
532
  // Idempotent clean-up of any prior injection: drop the provider table (marker-based) and every
494
533
  // stray/mis-nested model_provider line, so re-injecting can't duplicate keys or leave the buggy
@@ -526,6 +565,31 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
526
565
  keptUserBaseUrl = result.keptUserBaseUrl;
527
566
  }
528
567
 
568
+ const desiredSubagentDefaults = configuredManagedSubagentDefaults(config);
569
+ const routingOwnershipWarning = keptUserBaseUrl && desiredSubagentDefaults
570
+ ? "Native Codex sub-agent defaults were not injected: a user-owned root openai_base_url prevents OpenCodex from managing active Codex routing."
571
+ : undefined;
572
+ const managedDefaults = transformManagedSubagentDefaults(
573
+ content,
574
+ keptUserBaseUrl ? null : desiredSubagentDefaults,
575
+ );
576
+ let nativeSubagentDefaultsWarning = routingOwnershipWarning;
577
+ let managedDefaultsMessage = routingOwnershipWarning ? ` ⚠️ ${routingOwnershipWarning}\n` : "";
578
+ if (managedDefaults.ok) {
579
+ content = managedDefaults.content;
580
+ if (desiredSubagentDefaults && managedDefaults.conflicts.length > 0) {
581
+ const keys = managedDefaults.conflicts.map(conflict => `agents.${conflict.key}`).join(", ");
582
+ nativeSubagentDefaultsWarning = `Native Codex sub-agent defaults were not injected: user-owned ${keys} preserved.`;
583
+ managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`;
584
+ }
585
+ } else {
586
+ const action = desiredSubagentDefaults && !keptUserBaseUrl
587
+ ? "were not injected"
588
+ : "could not be safely removed";
589
+ nativeSubagentDefaultsWarning = `Native Codex sub-agent defaults ${action}: ${managedDefaults.error}.`;
590
+ managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`;
591
+ }
592
+
529
593
  const profileContent = buildProfileFile(port, catalogPath, websocketsEnabled(config ?? {}), legacyMode, config?.hostname);
530
594
  content = applyEol(content, eol);
531
595
  atomicWriteFile(CODEX_CONFIG_PATH, content);
@@ -560,9 +624,11 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
560
624
  if (keptUserBaseUrl) {
561
625
  return {
562
626
  success: true,
627
+ ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}),
563
628
  message: `⚠️ Codex routing NOT injected: your config already sets a root openai_base_url, and opencodex never overwrites a user-owned override.\n` +
564
629
  catalogMessage +
565
630
  historyMessage +
631
+ managedDefaultsMessage +
566
632
  ` To route plain codex through the proxy, remove your openai_base_url line from ~/.codex/config.toml and rerun 'ocx start'.\n` +
567
633
  ` Reference config: ${CODEX_PROFILE_PATH}`,
568
634
  };
@@ -572,9 +638,11 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
572
638
  : `Pointed Codex's built-in openai provider at the opencodex proxy (openai_base_url).\n`;
573
639
  return {
574
640
  success: true,
641
+ ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}),
575
642
  message: headline +
576
643
  catalogMessage +
577
644
  historyMessage +
645
+ managedDefaultsMessage +
578
646
  ` All models now route through opencodex proxy (like OpenRouter).\n` +
579
647
  ` OpenAI models (gpt-5.5, etc.) are passed through to OpenAI.\n` +
580
648
  ` Custom models route to their configured providers.\n` +
@@ -607,8 +675,17 @@ function removeOcxSection(content: string): string {
607
675
  return filtered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
608
676
  }
609
677
 
610
- /** Pure transform: strip the opencodex provider block + `model_provider = "opencodex"` lines. */
611
- export function stripOpencodexConfig(content: string): string {
678
+ interface StripOpencodexConfigResult {
679
+ content: string;
680
+ managedDefaultsError: string | null;
681
+ }
682
+
683
+ /**
684
+ * Detailed form used by the on-disk restore path. A damaged ownership marker is
685
+ * ambiguous: keep the associated value, but return the transform error so the
686
+ * caller cannot report a complete restore.
687
+ */
688
+ function stripOpencodexConfigResult(content: string): StripOpencodexConfigResult {
612
689
  let out = content;
613
690
  const hadRootOcxProvider = readRootTomlString(out, "model_provider") === "opencodex";
614
691
  const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out);
@@ -623,8 +700,18 @@ export function stripOpencodexConfig(content: string): string {
623
700
  // Routed root model ids (`model = "provider/slug"`) only make sense while the proxy serves
624
701
  // them — strip on both the legacy re-tag form and the Design B injected-base-url form.
625
702
  if (hadRootOcxProvider || hadInjectedBaseUrl) out = stripRootRoutedModel(out);
703
+ const managedDefaults = transformManagedSubagentDefaults(out, null);
704
+ if (managedDefaults.ok) out = managedDefaults.content;
626
705
  out = stripOpencodexCatalogPath(out);
627
- return out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
706
+ return {
707
+ content: out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n",
708
+ managedDefaultsError: !managedDefaults.ok ? managedDefaults.error : null,
709
+ };
710
+ }
711
+
712
+ /** Pure transform: strip the opencodex provider block + `model_provider = "opencodex"` lines. */
713
+ export function stripOpencodexConfig(content: string): string {
714
+ return stripOpencodexConfigResult(content).content;
628
715
  }
629
716
 
630
717
  function hasOpencodexRouting(content: string): boolean {
@@ -635,7 +722,11 @@ function hasOpencodexRouting(content: string): boolean {
635
722
 
636
723
  export function removeCodexConfig(options: { preserveProfile?: boolean } = {}): { success: boolean; message: string } {
637
724
  if (!existsSync(CODEX_CONFIG_PATH)) {
638
- return { success: false, message: "Codex config not found." };
725
+ if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH);
726
+ return {
727
+ success: true,
728
+ message: `Codex config not found; no native restore was needed${options.preserveProfile ? "." : ", and the opencodex profile was removed if present."}`,
729
+ };
639
730
  }
640
731
  const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8");
641
732
  // Same EOL boundary as inject: strip in LF space, write back in the file's own ending.
@@ -643,16 +734,25 @@ export function removeCodexConfig(options: { preserveProfile?: boolean } = {}):
643
734
  const eol = dominantEol(rawContent);
644
735
  const content = applyEol(rawContent, "\n");
645
736
  const had = hasOpencodexRouting(content);
646
- const stripped = stripOpencodexConfig(content);
647
- if (had || stripped !== content) {
648
- atomicWriteFile(CODEX_CONFIG_PATH, applyEol(stripped, eol));
737
+ const stripped = stripOpencodexConfigResult(content);
738
+ if (had || stripped.content !== content) {
739
+ atomicWriteFile(CODEX_CONFIG_PATH, applyEol(stripped.content, eol));
649
740
  }
650
741
  if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH);
742
+ const removedMessage = had
743
+ ? `Removed opencodex routing from Codex config${options.preserveProfile ? "." : " + profile."}`
744
+ : "opencodex not present in Codex config.";
745
+ if (stripped.managedDefaultsError) {
746
+ const routingMessage = had ? removedMessage : "No opencodex routing was present in Codex config.";
747
+ return {
748
+ success: false,
749
+ message: `${routingMessage} Native Codex sub-agent defaults could not be safely removed: ${stripped.managedDefaultsError}. ` +
750
+ "The ambiguous marker and adjacent value were preserved; inspect $CODEX_HOME/config.toml before using native Codex.",
751
+ };
752
+ }
651
753
  return {
652
754
  success: true,
653
- message: had
654
- ? `Removed opencodex routing from Codex config${options.preserveProfile ? "." : " + profile."}`
655
- : "opencodex not present in Codex config.",
755
+ message: removedMessage,
656
756
  };
657
757
  }
658
758
 
@@ -1,14 +1,8 @@
1
1
  import { readCodexTokens } from "./auth-collision";
2
2
  import { decodeJwtPayload } from "../oauth/chatgpt";
3
+ import { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
3
4
 
4
- /**
5
- * Stable id under which the "main" Codex account (the Codex CLI login stored in
6
- * ~/.codex/auth.json) participates in opencodex's account rotation. The main account is
7
- * NOT imported into the managed credential store (Option A): its token is read-only from
8
- * auth.json, so opencodex never refreshes it — an expired token surfaces as a reauth
9
- * notice (re-login via the Codex CLI) rather than a background refresh.
10
- */
11
- export const MAIN_CODEX_ACCOUNT_ID = "__main__";
5
+ export { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
12
6
 
13
7
  /**
14
8
  * Main account plan (e.g. "plus", "go", "free", "team"), populated from the WHAM usage
@@ -0,0 +1,186 @@
1
+ import type { OcxAccountPoolRotationStrategy } from "../types";
2
+
3
+ export const POOL_KEY_CODEX = "codex";
4
+ export const POOL_KEY_ANTHROPIC = "anthropic";
5
+
6
+ interface SelectionState {
7
+ activeKey?: string;
8
+ successes: number;
9
+ currentWeights: Map<string, number>;
10
+ }
11
+
12
+ const selectionState = new Map<string, SelectionState>();
13
+
14
+ const DEFAULT_STICKY_LIMIT = 1;
15
+ const MIN_STICKY_LIMIT = 1;
16
+ const MAX_STICKY_LIMIT = 100;
17
+ const DEFAULT_STRATEGY: OcxAccountPoolRotationStrategy = "quota";
18
+ const VALID_STRATEGIES = new Set<OcxAccountPoolRotationStrategy>(["quota", "round-robin", "fill-first"]);
19
+
20
+ /** Strict parse for management APIs — returns null instead of defaulting. */
21
+ export function parseAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | null {
22
+ if (typeof raw === "string" && VALID_STRATEGIES.has(raw as OcxAccountPoolRotationStrategy)) {
23
+ return raw as OcxAccountPoolRotationStrategy;
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /** Strict parse for management APIs — returns null instead of defaulting. */
29
+ export function parseAccountPoolStickyLimit(raw: unknown): number | null {
30
+ if (typeof raw === "number" && Number.isInteger(raw) && raw >= MIN_STICKY_LIMIT && raw <= MAX_STICKY_LIMIT) {
31
+ return raw;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ export function normalizeAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy {
37
+ return parseAccountPoolStrategy(raw) ?? DEFAULT_STRATEGY;
38
+ }
39
+
40
+ export function normalizeAccountPoolStickyLimit(raw: unknown): number {
41
+ return parseAccountPoolStickyLimit(raw) ?? DEFAULT_STICKY_LIMIT;
42
+ }
43
+
44
+ function getOrCreateState(poolKey: string): SelectionState {
45
+ let state = selectionState.get(poolKey);
46
+ if (!state) {
47
+ state = { successes: 0, currentWeights: new Map() };
48
+ selectionState.set(poolKey, state);
49
+ }
50
+ return state;
51
+ }
52
+
53
+ function cloneSelectionState(state: SelectionState): SelectionState {
54
+ return {
55
+ activeKey: state.activeKey,
56
+ successes: state.successes,
57
+ currentWeights: new Map(state.currentWeights),
58
+ };
59
+ }
60
+
61
+ function smoothWeightedIndex(ids: string[], state: SelectionState): number {
62
+ let best = -1;
63
+ let bestScore = Number.NEGATIVE_INFINITY;
64
+ let total = 0;
65
+ const weight = 1;
66
+ for (let i = 0; i < ids.length; i++) {
67
+ const id = ids[i]!;
68
+ const score = (state.currentWeights.get(id) ?? 0) + weight;
69
+ state.currentWeights.set(id, score);
70
+ total += weight;
71
+ if (score > bestScore) {
72
+ best = i;
73
+ bestScore = score;
74
+ }
75
+ }
76
+ if (best >= 0) {
77
+ const key = ids[best]!;
78
+ state.currentWeights.set(key, (state.currentWeights.get(key) ?? 0) - total);
79
+ }
80
+ return best;
81
+ }
82
+
83
+ /**
84
+ * Shared pick core. Mutates `state` the same way live resolve does; callers pass
85
+ * either the live map entry or a scratch/clone for dry-run peek.
86
+ */
87
+ function pickRoundRobinFromState(
88
+ eligibleIds: string[],
89
+ stickyLimit: number,
90
+ state: SelectionState,
91
+ commitSticky: boolean,
92
+ ): string | null {
93
+ if (eligibleIds.length === 0) return null;
94
+
95
+ const limit = normalizeAccountPoolStickyLimit(stickyLimit);
96
+
97
+ if (state.activeKey && eligibleIds.includes(state.activeKey)) {
98
+ return state.activeKey;
99
+ }
100
+
101
+ if (state.activeKey) {
102
+ delete state.activeKey;
103
+ state.successes = 0;
104
+ }
105
+
106
+ const index = smoothWeightedIndex(eligibleIds, state);
107
+ if (index < 0) return null;
108
+
109
+ const picked = eligibleIds[index]!;
110
+ if (commitSticky && limit > 1) {
111
+ state.activeKey = picked;
112
+ state.successes = 0;
113
+ }
114
+ return picked;
115
+ }
116
+
117
+ export function pickRoundRobinAccount(
118
+ poolKey: string,
119
+ eligibleIds: string[],
120
+ stickyLimit: number,
121
+ ): string | null {
122
+ return pickRoundRobinFromState(eligibleIds, stickyLimit, getOrCreateState(poolKey), true);
123
+ }
124
+
125
+ /**
126
+ * Dry-run of {@link pickRoundRobinAccount}: returns the same account resolve would
127
+ * pick without advancing ring weights, activeKey, or successes.
128
+ */
129
+ export function peekRoundRobinAccount(
130
+ poolKey: string,
131
+ eligibleIds: string[],
132
+ stickyLimit: number,
133
+ ): string | null {
134
+ const live = selectionState.get(poolKey);
135
+ const scratch = live
136
+ ? cloneSelectionState(live)
137
+ : { successes: 0, currentWeights: new Map<string, number>() };
138
+ return pickRoundRobinFromState(eligibleIds, stickyLimit, scratch, false);
139
+ }
140
+
141
+ export function notePoolRotationSuccess(
142
+ poolKey: string,
143
+ accountId: string,
144
+ stickyLimit: number,
145
+ ): void {
146
+ const limit = normalizeAccountPoolStickyLimit(stickyLimit);
147
+ const state = selectionState.get(poolKey);
148
+ if (!state) return;
149
+ if (state.activeKey !== accountId) {
150
+ state.activeKey = accountId;
151
+ state.successes = 0;
152
+ }
153
+ state.successes += 1;
154
+ if (state.successes >= limit) {
155
+ delete state.activeKey;
156
+ state.successes = 0;
157
+ }
158
+ }
159
+
160
+ export function notePoolRotationFailure(poolKey: string, accountId: string): void {
161
+ const state = selectionState.get(poolKey);
162
+ if (state?.activeKey === accountId) {
163
+ delete state.activeKey;
164
+ state.successes = 0;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Force the next sticky/RR pick onto `accountId` (manual dashboard selection).
170
+ * Clears sticky success counters and ring weights so the seeded account is held
171
+ * for the next new-session pick before ordinary rotation resumes.
172
+ */
173
+ export function seedPoolRotationAccount(poolKey: string, accountId: string): void {
174
+ const state = getOrCreateState(poolKey);
175
+ state.activeKey = accountId;
176
+ state.successes = 0;
177
+ state.currentWeights.clear();
178
+ }
179
+
180
+ export function clearPoolRotationState(poolKey?: string): void {
181
+ if (poolKey === undefined) {
182
+ selectionState.clear();
183
+ return;
184
+ }
185
+ selectionState.delete(poolKey);
186
+ }
@@ -33,6 +33,21 @@ const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60;
33
33
  const accountQuota = new Map<string, StoredAccountQuota>();
34
34
 
35
35
  export const CODEX_UNKNOWN_USAGE_SCORE = 100;
36
+ export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
37
+
38
+ export function isCodexQuotaExhausted(
39
+ quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent"> | null,
40
+ plan?: string | null,
41
+ ): boolean {
42
+ if (!quota) return false;
43
+ const normalizedPlan = plan?.trim().toLowerCase();
44
+ const values = normalizedPlan === "go" || normalizedPlan === "free"
45
+ ? [quota.monthlyPercent]
46
+ : [quota.weeklyPercent, quota.monthlyPercent];
47
+ return values.some(value => typeof value === "number"
48
+ && Number.isFinite(value)
49
+ && value >= CODEX_EXHAUSTED_USAGE_PERCENT);
50
+ }
36
51
 
37
52
  export function normalizeUsagePercent(value: unknown): number | undefined {
38
53
  const numeric = typeof value === "number"