@yansigit/opencodex 2.33.0 → 2.35.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 (196) hide show
  1. package/README.md +3 -3
  2. package/gui/dist/assets/index-BjCaHxdz.js +112 -0
  3. package/gui/dist/assets/index-DLkXOXLC.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +79 -2
  7. package/src/adapters/command-code.ts +141 -23
  8. package/src/adapters/cursor/call-id.ts +44 -0
  9. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  10. package/src/adapters/cursor/discovery.ts +60 -2
  11. package/src/adapters/cursor/effort-map.ts +79 -1
  12. package/src/adapters/cursor/envelope-echo.ts +162 -0
  13. package/src/adapters/cursor/live-models.ts +7 -2
  14. package/src/adapters/cursor/live-transport.ts +17 -1
  15. package/src/adapters/cursor/message-mapper.ts +4 -1
  16. package/src/adapters/cursor/native-exec-fs.ts +13 -12
  17. package/src/adapters/cursor/native-exec-network.ts +3 -5
  18. package/src/adapters/cursor/native-exec-policy.ts +47 -0
  19. package/src/adapters/cursor/native-exec-shell.ts +116 -31
  20. package/src/adapters/cursor/native-exec.ts +38 -10
  21. package/src/adapters/cursor/protobuf-events.ts +28 -2
  22. package/src/adapters/cursor/protobuf-request.ts +93 -41
  23. package/src/adapters/cursor/request-builder.ts +39 -10
  24. package/src/adapters/cursor/tool-definitions.ts +27 -3
  25. package/src/adapters/cursor/tool-result-normalize.ts +51 -6
  26. package/src/adapters/cursor/types.ts +23 -4
  27. package/src/adapters/cursor.ts +170 -29
  28. package/src/adapters/google-aistudio-parser.ts +49 -0
  29. package/src/adapters/google-antigravity-replay.ts +105 -25
  30. package/src/adapters/google-antigravity-wire.ts +5 -0
  31. package/src/adapters/google-errors.ts +41 -12
  32. package/src/adapters/google-http.ts +12 -11
  33. package/src/adapters/google.ts +219 -36
  34. package/src/adapters/image.ts +1 -1
  35. package/src/adapters/kiro-constants.ts +15 -0
  36. package/src/adapters/kiro-tools.ts +43 -15
  37. package/src/adapters/kiro.ts +54 -9
  38. package/src/adapters/openai-chat.ts +286 -242
  39. package/src/adapters/openai-responses.ts +335 -24
  40. package/src/adapters/run-turn-queue.ts +36 -1
  41. package/src/adapters/tool-catalog-nudge.ts +2 -2
  42. package/src/adapters/xai-tool-schema.ts +436 -0
  43. package/src/bridge.ts +67 -26
  44. package/src/chat/inbound.ts +29 -1
  45. package/src/chat/outbound.ts +15 -7
  46. package/src/claude/agents-inject.ts +8 -1
  47. package/src/claude/outbound.ts +10 -8
  48. package/src/cli/account-api.ts +27 -7
  49. package/src/cli/account-extended.ts +10 -3
  50. package/src/cli/account.ts +29 -5
  51. package/src/cli/alias.ts +66 -0
  52. package/src/cli/claude.ts +26 -1
  53. package/src/cli/dispatch.ts +13 -1
  54. package/src/cli/help.ts +1 -0
  55. package/src/cli/index.ts +6 -1
  56. package/src/cli/init.ts +1 -0
  57. package/src/cli/models-runtime.ts +95 -0
  58. package/src/cli/models.ts +13 -7
  59. package/src/cli/provider-runtime.ts +16 -2
  60. package/src/cli/registry.ts +6 -1
  61. package/src/cli/telemetry-commands.ts +25 -0
  62. package/src/cli/v2.ts +34 -10
  63. package/src/codex/account-pause.ts +2 -1
  64. package/src/codex/account-priority.ts +3 -2
  65. package/src/codex/app-server-processes.ts +80 -6
  66. package/src/codex/auth-api.ts +48 -8
  67. package/src/codex/auth-context.ts +21 -18
  68. package/src/codex/catalog/aggregation.ts +6 -0
  69. package/src/codex/catalog/model-metadata.ts +13 -1
  70. package/src/codex/catalog/native-models.ts +5 -2
  71. package/src/codex/catalog/parsing.ts +16 -0
  72. package/src/codex/catalog/provider-fetch.ts +20 -3
  73. package/src/codex/catalog/sync.ts +127 -2
  74. package/src/codex/catalog.ts +1 -1
  75. package/src/codex/codex-write-lock.ts +3 -1
  76. package/src/codex/convergence-types.ts +1 -1
  77. package/src/codex/convergence.ts +22 -2
  78. package/src/codex/desired-state.ts +2 -2
  79. package/src/codex/desktop-app-restart.ts +18 -5
  80. package/src/codex/inject-coordination.ts +83 -0
  81. package/src/codex/inject.ts +14 -1
  82. package/src/codex/log-guard/inspect.ts +22 -4
  83. package/src/codex/model-entitlements.ts +9 -2
  84. package/src/codex/prompt-layers.ts +371 -25
  85. package/src/codex/prompt-text-probe.ts +238 -0
  86. package/src/codex/quota.ts +123 -18
  87. package/src/codex/routing.ts +9 -0
  88. package/src/codex/subagent-model-fallback.ts +198 -27
  89. package/src/codex/transition-state.ts +107 -8
  90. package/src/combos/types.ts +10 -0
  91. package/src/compatibility/openai-responses.ts +33 -1
  92. package/src/config/autonomous-remediation.ts +21 -0
  93. package/src/config/provider-validation.ts +14 -0
  94. package/src/config/rebase-provenance.ts +68 -0
  95. package/src/config.ts +191 -17
  96. package/src/generated/compatibility-version.json +279 -159
  97. package/src/generated/model-metadata.ts +3 -0
  98. package/src/images/loop.ts +5 -4
  99. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  100. package/src/lab/fabric/producer-child.ts +1 -1
  101. package/src/lib/config-ownership.ts +20 -0
  102. package/src/lib/errors.ts +11 -2
  103. package/src/lib/package-tree-integrity.ts +101 -0
  104. package/src/oauth/aistudio-credentials.ts +65 -0
  105. package/src/oauth/aistudio-native-daemon.ts +116 -0
  106. package/src/oauth/aistudio-session-sync.ts +95 -0
  107. package/src/oauth/generic-account-failover.ts +231 -0
  108. package/src/oauth/google-aistudio-auth.ts +98 -0
  109. package/src/oauth/index.ts +57 -5
  110. package/src/oauth/key-providers.ts +18 -1
  111. package/src/oauth/kiro.ts +45 -0
  112. package/src/oauth/login-cli.ts +65 -1
  113. package/src/oauth/types.ts +15 -0
  114. package/src/providers/codex-capacity.ts +5 -2
  115. package/src/providers/command-code-efforts.ts +38 -6
  116. package/src/providers/context-cap.ts +4 -3
  117. package/src/providers/default-aliases.ts +65 -0
  118. package/src/providers/derive.ts +29 -1
  119. package/src/providers/fastwire.ts +7 -1
  120. package/src/providers/model-presets.ts +119 -0
  121. package/src/providers/new-model-policy.ts +146 -0
  122. package/src/providers/provider-id-rewrite.ts +2 -1
  123. package/src/providers/quota.ts +157 -46
  124. package/src/providers/registry.ts +184 -71
  125. package/src/providers/slug-codec.ts +52 -0
  126. package/src/responses/code-mode-helper-compat.ts +50 -0
  127. package/src/responses/custom-tool-compat.ts +34 -10
  128. package/src/responses/parser.ts +4 -0
  129. package/src/responses/schema.ts +5 -1
  130. package/src/responses/thought-signature-replay.ts +17 -0
  131. package/src/router.ts +43 -2
  132. package/src/routing/account-pool/cooldown.ts +8 -0
  133. package/src/routing/account-pool/index.ts +1 -0
  134. package/src/routing/analytics.ts +1 -0
  135. package/src/routing/quota.ts +10 -0
  136. package/src/server/auth-cors.ts +24 -0
  137. package/src/server/chat-completions.ts +26 -16
  138. package/src/server/chat-native-sse.ts +3 -3
  139. package/src/server/chat-native.ts +30 -11
  140. package/src/server/claude-messages.ts +1 -1
  141. package/src/server/effort-policy.ts +16 -0
  142. package/src/server/index.ts +180 -14
  143. package/src/server/lifecycle.ts +52 -1
  144. package/src/server/management/agent-settings-routes.ts +31 -15
  145. package/src/server/management/codex-prompt-routes.ts +570 -0
  146. package/src/server/management/combo-routes.ts +2 -1
  147. package/src/server/management/config-routes.ts +27 -9
  148. package/src/server/management/context.ts +9 -0
  149. package/src/server/management/logs-usage-routes.ts +11 -5
  150. package/src/server/management/model-routes.ts +266 -0
  151. package/src/server/management/oauth-account-routes.ts +13 -3
  152. package/src/server/management/provider-routes.ts +137 -3
  153. package/src/server/management/routing-profile-routes.ts +2 -2
  154. package/src/server/management-api.ts +2 -0
  155. package/src/server/port-reclaim.ts +19 -1
  156. package/src/server/relay-eager.ts +147 -20
  157. package/src/server/relay.ts +251 -19
  158. package/src/server/request-log-conversation.ts +33 -0
  159. package/src/server/request-log.ts +48 -21
  160. package/src/server/responses/collaboration.ts +42 -5
  161. package/src/server/responses/combo-stream-preflight.ts +10 -3
  162. package/src/server/responses/core.ts +575 -140
  163. package/src/server/responses/empty-completion-guard.ts +35 -0
  164. package/src/server/responses/fetch-helpers.ts +14 -6
  165. package/src/server/responses/input-admission.ts +3 -1
  166. package/src/server/responses/passthrough-error.ts +33 -9
  167. package/src/server/responses/policy-fallback.ts +1 -1
  168. package/src/server/responses/responses-field-backfill.ts +105 -13
  169. package/src/server/responses/ws-upstream.ts +35 -5
  170. package/src/server/responses-custom-tool-repair.ts +52 -7
  171. package/src/server/responses-terminal-repair.ts +25 -4
  172. package/src/server/sse-frame-buffer.ts +31 -4
  173. package/src/server/ws-bridge.ts +14 -2
  174. package/src/smoke/fingerprint-cache.ts +133 -0
  175. package/src/smoke/live-scenarios.ts +33 -0
  176. package/src/smoke/runner.ts +119 -0
  177. package/src/telemetry/dispatcher.ts +44 -0
  178. package/src/telemetry/fingerprint.ts +24 -0
  179. package/src/telemetry/hook.ts +43 -0
  180. package/src/telemetry/ledger.ts +54 -0
  181. package/src/telemetry/types.ts +23 -0
  182. package/src/types/config.ts +66 -14
  183. package/src/types/provider.ts +79 -1
  184. package/src/types/request.ts +18 -10
  185. package/src/types/tools.ts +30 -11
  186. package/src/types.ts +1 -0
  187. package/src/usage/command-code-manifest.ts +116 -0
  188. package/src/usage/cost.ts +2 -2
  189. package/src/usage/expected-prices.ts +126 -24
  190. package/src/usage/log.ts +18 -8
  191. package/src/usage/summary.ts +34 -12
  192. package/src/web-search/exa-executor.ts +40 -9
  193. package/src/web-search/index.ts +16 -8
  194. package/src/web-search/loop.ts +5 -4
  195. package/gui/dist/assets/index-DKLr4LTE.js +0 -102
  196. package/gui/dist/assets/index-DrSQdTRd.css +0 -1
@@ -22,6 +22,10 @@ const USAGE = `Usage:
22
22
  ocx models <enable|disable> <provider/model|native-model> [--native] [--json]
23
23
  ocx models provider <name> <on|off> [--json]
24
24
  ocx models selected <provider> [--set <id,id...>|--clear] [--json]
25
+ ocx models preset show [--provider <name>] [--json]
26
+ ocx models preset apply <provider> [--all] [--json]
27
+ ocx models new-policy [on|off] [--provider <name>] [--json]
28
+ ocx models new-arrivals [--json]
25
29
  ocx models context <status|value <tokens> [--set-all]|provider <name> on [--value <tokens>]|provider <name> off|all <on|off>> [--json]
26
30
  ocx models shadow <status|set> [model|-] [--enabled <on|off>] [--json]`;
27
31
 
@@ -162,6 +166,94 @@ async function selected(argv: string[], deps: RuntimeApiDeps): Promise<void> {
162
166
  printData(result, wantsJson, [`${provider}: ${models.length ? models.join(", ") : "all models"}`]);
163
167
  }
164
168
 
169
+
170
+ interface ModelPresetView {
171
+ mode: string;
172
+ appliedVersion?: number;
173
+ availableVersion: number;
174
+ presetIds: string[];
175
+ presetCount: number;
176
+ totalCount: number;
177
+ fallback?: string;
178
+ }
179
+
180
+ function presetLine(name: string, view: ModelPresetView): string {
181
+ const parts = [`${name}: mode=${view.mode}`];
182
+ if (view.appliedVersion !== undefined && view.appliedVersion !== view.availableVersion) {
183
+ parts.push(`applied v${view.appliedVersion}, available v${view.availableVersion}`);
184
+ } else {
185
+ parts.push(`preset v${view.availableVersion}`);
186
+ }
187
+ parts.push(`(${view.presetCount} of ${view.totalCount} models)`);
188
+ if (view.fallback) parts.push(`fallback=${view.fallback}`);
189
+ return parts.join(" ");
190
+ }
191
+
192
+ async function preset(argv: string[], deps: RuntimeApiDeps): Promise<void> {
193
+ const args = [...argv];
194
+ const action = (args.shift() ?? "show").toLowerCase();
195
+ const wantsJson = takeFlag(args, "--json");
196
+ if (action === "show") {
197
+ const only = takeOption(args, "--provider")?.trim();
198
+ rejectArgs(args, USAGE);
199
+ const result = await runtimeRequest<{ providers?: Record<string, ModelPresetView> }>("/api/model-presets", {}, deps);
200
+ const providers = result.providers ?? {};
201
+ const entries = Object.entries(providers).filter(([name]) => !only || name === only);
202
+ const lines = entries.length > 0
203
+ ? entries.map(([name, view]) => presetLine(name, view))
204
+ // A provider with no shipped preset is not an error: it simply has nothing to curate.
205
+ : [only ? `${only}: no model preset is shipped for this provider` : "no providers have a shipped model preset"];
206
+ printData(only ? providers[only] ?? {} : result, wantsJson, lines);
207
+ return;
208
+ }
209
+ if (action !== "apply") throw new CliUsageError(`unknown preset action '${action}'`, USAGE);
210
+ const provider = args.shift()?.trim();
211
+ const all = takeFlag(args, "--all");
212
+ if (!provider) throw new CliUsageError("provider is required", USAGE);
213
+ rejectArgs(args, USAGE);
214
+ const mode = all ? "all" : "preset";
215
+ const result = await runtimeRequest<{ selected?: string[]; fallback?: string; appliedVersion?: number }>(
216
+ "/api/model-presets",
217
+ { method: "PUT", body: JSON.stringify({ provider, mode }) },
218
+ deps,
219
+ );
220
+ const selectedIds = result.selected ?? [];
221
+ const line = result.fallback === "preset-empty"
222
+ // Never silently narrow to nothing: empty means ALL, so a zero-match preset keeps what was
223
+ // there and says so.
224
+ ? `${provider}: preset matched no models — selection unchanged (fallback to all)`
225
+ : all
226
+ ? `${provider}: showing all models (allowlist cleared)`
227
+ : `${provider}: preset v${result.appliedVersion ?? "?"} applied — ${selectedIds.length} models selected`;
228
+ printData(result, wantsJson, [line]);
229
+ }
230
+
231
+ async function newPolicy(argv: string[], deps: RuntimeApiDeps): Promise<void> {
232
+ const args = [...argv];
233
+ const state = args[0] && !args[0].startsWith("--") ? args.shift()!.toLowerCase() : undefined;
234
+ const provider = takeOption(args, "--provider")?.trim();
235
+ const wantsJson = takeFlag(args, "--json");
236
+ if (state !== undefined && state !== "on" && state !== "off") throw new CliUsageError("new policy must be on or off", USAGE);
237
+ rejectArgs(args, USAGE);
238
+ if (!state) {
239
+ const result = await runtimeRequest<{ policy: string; providers: Record<string, string> }>("/api/model-discovery", {}, deps);
240
+ const value = provider ? result.providers[provider] ?? "inherit" : result.policy;
241
+ printData(provider ? { provider, policy: value } : result, wantsJson, [`${provider ?? "global"}: ${value}`]);
242
+ return;
243
+ }
244
+ const result = await runtimeRequest<{ baselineBootstrapped?: boolean }>("/api/model-discovery", {
245
+ method: "PUT", body: JSON.stringify({ policy: state, provider: provider ?? null }),
246
+ }, deps);
247
+ printData(result, wantsJson, [`${provider ?? "global"}: ${state}${result.baselineBootstrapped ? " (current models recorded as known)" : ""}`]);
248
+ }
249
+
250
+ async function newArrivals(argv: string[], deps: RuntimeApiDeps): Promise<void> {
251
+ const args = [...argv]; const wantsJson = takeFlag(args, "--json"); rejectArgs(args, USAGE);
252
+ const result = await runtimeRequest<{ recentArrivals: Record<string, Array<{ id: string; at: string; state: string }>> }>("/api/model-discovery", {}, deps);
253
+ const lines = Object.entries(result.recentArrivals).flatMap(([provider, rows]) => rows.map(row => `${provider}/${row.id} [${row.state}] ${row.at}`));
254
+ printData(result.recentArrivals, wantsJson, lines.length ? lines : ["no recent model arrivals"]);
255
+ }
256
+
165
257
  async function context(argv: string[], deps: RuntimeApiDeps): Promise<void> {
166
258
  const args = [...argv];
167
259
  const action = (args.shift() ?? "status").toLowerCase();
@@ -236,6 +328,9 @@ export async function handleModelsRuntimeCommand(sub: string, argv: string[], de
236
328
  else if (sub === "disable") action = () => visibility(false, argv, deps);
237
329
  else if (sub === "provider") action = () => providerVisibility(argv, deps);
238
330
  else if (sub === "selected") action = () => selected(argv, deps);
331
+ else if (sub === "preset") action = () => preset(argv, deps);
332
+ else if (sub === "new-policy") action = () => newPolicy(argv, deps);
333
+ else if (sub === "new-arrivals") action = () => newArrivals(argv, deps);
239
334
  else if (sub === "context") action = () => context(argv, deps);
240
335
  else if (sub === "shadow") action = () => shadow(argv, deps);
241
336
  if (!action) return null;
package/src/cli/models.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  isDeclaredReasoningEffort,
12
12
  modelRecordValue,
13
13
  } from "../reasoning-effort";
14
- import { encodedModelIdCollides, routedSlug, slugEquals } from "../providers/slug-codec";
14
+ import { encodedModelIdCollides, resolveSlugSelection, routedSlug } from "../providers/slug-codec";
15
15
  import { knownModelIdsForProvider } from "../router";
16
16
  import { findLiveProxy } from "../server/proxy-liveness";
17
17
  import { modelInList, type OcxConfig, type OcxCustomModel } from "../types";
@@ -277,11 +277,17 @@ async function handleCustomRemove(args: string[]): Promise<void> {
277
277
 
278
278
  const config = loadConfig();
279
279
  const existing = config.customModels ?? [];
280
- const matchingIndexes = existing.flatMap((model, index) => (
281
- target.includes("/")
282
- ? slugEquals(target, model.provider, model.modelId)
283
- : model.id === target
284
- ) ? [index] : []);
280
+ // Slug matching goes through the shared resolver so this command sees the same collision
281
+ // class catalog filtering and persisted sync see (#2491). `slugEquals` compares the raw and
282
+ // encoded spellings of ONE id, so a selector written in the native slash form matched only
283
+ // that row while the dash form matched both — the two relations disagreed on the same
284
+ // config. Removal stays exact-or-refuse: an ambiguous selector still aborts below, which is
285
+ // the right default for a destructive command.
286
+ const matchingIndexes = existing.flatMap((model, index) => {
287
+ if (!target.includes("/")) return model.id === target ? [index] : [];
288
+ const resolved = resolveSlugSelection(model.provider, target, [model.modelId]);
289
+ return resolved.matched.length > 0 ? [index] : [];
290
+ });
285
291
  if (matchingIndexes.length === 0) fail(`custom model "${target}" not found`);
286
292
  if (matchingIndexes.length > 1) {
287
293
  fail(`custom model selector "${target}" is ambiguous; use the custom model id`);
@@ -422,7 +428,7 @@ export async function handleModels(args: string[]): Promise<void> {
422
428
  handleCustomList(rest);
423
429
  return;
424
430
  }
425
- if (["live", "edit", "enable", "disable", "provider", "selected", "context", "shadow"].includes(subcommand ?? "")) {
431
+ if (["live", "edit", "enable", "disable", "provider", "selected", "preset", "context", "shadow"].includes(subcommand ?? "")) {
426
432
  const { handleModelsRuntimeCommand } = await import("./models-runtime");
427
433
  const code = await handleModelsRuntimeCommand(subcommand!, rest);
428
434
  if (code !== null) process.exitCode = code;
@@ -11,6 +11,13 @@ import {
11
11
  takeOption,
12
12
  type RuntimeApiDeps,
13
13
  } from "./runtime-api";
14
+ import { providerQuotaLine } from "./account-extended";
15
+ import type { ProviderQuotaReportDto } from "./account-api";
16
+
17
+ interface ProviderQuotasDto {
18
+ generatedAt?: number;
19
+ reports?: ProviderQuotaReportDto[];
20
+ }
14
21
 
15
22
  const USAGE = `Usage:
16
23
  ocx provider edit <name> [--adapter <id>] [--base-url <url>] [--default-model <id|->]
@@ -107,8 +114,15 @@ async function quota(argv: string[], deps: RuntimeApiDeps): Promise<void> {
107
114
  const wantsJson = takeFlag(args, "--json");
108
115
  const refresh = takeFlag(args, "--refresh");
109
116
  rejectArgs(args, USAGE);
110
- const result = await runtimeRequest(`/api/provider-quotas${refresh ? "?refresh=1" : ""}`, {}, deps);
111
- printData(result, wantsJson, summaryLines(result));
117
+ const result = await runtimeRequest<ProviderQuotasDto>(`/api/provider-quotas${refresh ? "?refresh=1" : ""}`, {}, deps);
118
+ // `summaryLines` is a depth-1 flattener: it renders a non-scalar array as "N item(s)", which
119
+ // collapsed the whole report to a count and made the command useless for its stated purpose
120
+ // (#2565). Render one line per report with the same formatter `ocx account refresh` uses.
121
+ const reports = Array.isArray(result?.reports) ? result.reports : [];
122
+ const lines = reports.length > 0
123
+ ? reports.map(report => providerQuotaLine(report.provider, report))
124
+ : ["no quota reports available"];
125
+ printData(result, wantsJson, lines);
112
126
  }
113
127
 
114
128
  async function presets(argv: string[], deps: RuntimeApiDeps): Promise<void> {
@@ -165,10 +165,15 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
165
165
  "A selection-order change applies from the next unbound request and never moves a bound thread.",
166
166
  ],
167
167
  },
168
+ {
169
+ name: "alias",
170
+ usage: "ocx alias <list|set|rm|defaults> ...",
171
+ summary: "Manage short provider and model names.",
172
+ },
168
173
  {
169
174
  name: "models",
170
175
  aliases: ["model"],
171
- usage: "ocx models <list|live|add|edit|remove|enable|disable|provider|selected|context|shadow> ...",
176
+ usage: "ocx models <list|live|add|edit|remove|enable|disable|provider|selected|preset|context|shadow> ...",
172
177
  summary: "List models and manage custom (manually registered) models.",
173
178
  details: [
174
179
  "List available models from static config with no subcommand (liveModels may add more at runtime).",
@@ -0,0 +1,25 @@
1
+ import { TelemetryLedger } from "../telemetry/ledger";
2
+ import type { OcxConfig } from "../types";
3
+
4
+ export function runTelemetryCommand(args: string[], config: OcxConfig): number {
5
+ if (args[0] !== "status") {
6
+ console.error("Usage: ocx telemetry status");
7
+ return 1;
8
+ }
9
+ const ledger = new TelemetryLedger();
10
+ try {
11
+ const records = ledger.listRecords();
12
+ console.log(`Telemetry failures: ${records.length}`);
13
+ for (const record of records) {
14
+ console.log(`${record.fingerprint} ${record.status} count=${record.count} lastSeen=${new Date(record.lastSeen).toISOString()}`);
15
+ }
16
+ if ((config as OcxConfig & { autonomousRemediation?: { enabled?: boolean; instanceId?: string } }).autonomousRemediation?.enabled === true) {
17
+ console.log("Autonomous remediation: enabled");
18
+ } else {
19
+ console.log("Autonomous remediation: disabled");
20
+ }
21
+ return 0;
22
+ } finally {
23
+ ledger.close();
24
+ }
25
+ }
package/src/cli/v2.ts CHANGED
@@ -15,7 +15,7 @@ import { dirname } from "node:path";
15
15
  import { activeCodexConfigPath, getAgentsEnabled, getAgentsMaxDepth, getLogicalMaxThreads, getMultiAgentModeHintText, getSubagentDeveloperInstructions, hasAgentsMaxThreads, isMultiAgentV2Enabled, setMultiAgentModeHintText, transitionMultiAgentV2 } from "../codex/features";
16
16
 
17
17
  import { commandInvocation, type SpawnInvocation } from "../lib/win-exec";
18
- import { loadConfig, saveConfig } from "../config";
18
+ import { deleteConfigTopLevelKey, loadConfig, saveConfig } from "../config";
19
19
  import { resolveAndPersistCodexRuntime, type ResolveCodexRuntimeDeps } from "../codex/runtime";
20
20
 
21
21
  export interface V2CliDeps {
@@ -88,18 +88,24 @@ function runCodexFeatures(action: "enable" | "disable", deps: V2CliDeps): void {
88
88
 
89
89
  export function v2StatusLine(enabled: boolean): string {
90
90
  return enabled
91
- ? "multi_agent_v2: ON — v2 multi-agent surface active"
92
- : "multi_agent_v2: OFF — v1 multi-agent surface (default install)";
91
+ ? "multi_agent_v2: ON — global V2 override active"
92
+ : "multi_agent_v2: OFF — model catalog pins and defaults decide the surface";
93
93
  }
94
94
 
95
- export function multiAgentModeLine(mode: string): string {
95
+ export function multiAgentModeLine(mode: string, keepNativeChatGptOnV1 = false): string {
96
96
  switch (mode) {
97
97
  case "v1": return "multi_agent_mode: v1 — ALL models forced to v1 surface (upstream pins overridden)";
98
- case "v2": return "multi_agent_mode: v2 — ALL models forced to v2 surface (upstream pins overridden)";
98
+ case "v2": return keepNativeChatGptOnV1
99
+ ? "multi_agent_mode: v2 hybrid — ChatGPT-native models use v1; routed models use v2"
100
+ : "multi_agent_mode: v2 — ALL models forced to v2 surface (upstream pins overridden)";
99
101
  default: return "multi_agent_mode: default — upstream model pins respected (sol/terra=v2, luna=v1, rest=codex flag)";
100
102
  }
101
103
  }
102
104
 
105
+ function requiresGlobalV2Disabled(multiAgentMode: string | undefined, keepNativeChatGptOnV1: boolean): boolean {
106
+ return multiAgentMode === "v2" && keepNativeChatGptOnV1;
107
+ }
108
+
103
109
  export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () => Promise<number | undefined>): Promise<number> {
104
110
  const log = deps.log ?? console;
105
111
  const isEnabled = deps.isEnabled ?? isMultiAgentV2Enabled;
@@ -109,9 +115,13 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
109
115
  if (verb === "status") {
110
116
  log.log(v2StatusLine(isEnabled()));
111
117
  const cfg = loadConfig();
112
- log.log(multiAgentModeLine(cfg.multiAgentMode ?? "default"));
118
+ const mode = cfg.multiAgentMode ?? "default";
119
+ const keepNativeV1 = cfg.keepNativeChatGptOnV1 === true;
120
+ log.log(multiAgentModeLine(mode, keepNativeV1));
113
121
  log.log(cfg.keepNativeChatGptOnV1 === true
114
- ? "keep_native_chatgpt_on_v1: ON ChatGPT-native rows stay v1 when mode is v2"
122
+ ? requiresGlobalV2Disabled(mode, keepNativeV1) && isEnabled()
123
+ ? "keep_native_chatgpt_on_v1: CONFLICT — global multi_agent_v2 overrides the native v1 catalog pin; run 'ocx v2 keep-native-v1 on' to reconcile"
124
+ : "keep_native_chatgpt_on_v1: ON — global V2 override is off; ChatGPT-native rows use v1 and routed rows use v2 when mode is v2"
115
125
  : "keep_native_chatgpt_on_v1: OFF");
116
126
  const threads = getLogicalMaxThreads();
117
127
  log.log(`max_threads: ${threads ?? "(unset — codex default)"}`);
@@ -186,14 +196,14 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
186
196
  }
187
197
  const cfg = loadConfig();
188
198
  if (modeArg !== "default") {
189
- const target = modeArg === "v2";
199
+ const target = modeArg === "v2" && cfg.keepNativeChatGptOnV1 !== true;
190
200
  const transition = transitionMultiAgentV2(target, enabled => runCodexFeatures(enabled ? "enable" : "disable", deps));
191
201
  if (!transition.ok) {
192
202
  log.error(`multi-agent mode transition failed: ${transition.error}`);
193
203
  return 1;
194
204
  }
195
205
  }
196
- if (modeArg === "default") delete cfg.multiAgentMode;
206
+ if (modeArg === "default") deleteConfigTopLevelKey(cfg, "multiAgentMode");
197
207
  else cfg.multiAgentMode = modeArg as "v1" | "v2";
198
208
  saveConfig(cfg);
199
209
  try {
@@ -216,8 +226,15 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
216
226
  const cfg = loadConfig();
217
227
  const next = flag === "on";
218
228
  const already = cfg.keepNativeChatGptOnV1 === true === next;
229
+ if (next && requiresGlobalV2Disabled(cfg.multiAgentMode, true)) {
230
+ const transition = transitionMultiAgentV2(false, enabled => runCodexFeatures(enabled ? "enable" : "disable", deps));
231
+ if (!transition.ok) {
232
+ log.error(`keep-native-v1 transition failed: ${transition.error}`);
233
+ return 1;
234
+ }
235
+ }
219
236
  if (next) cfg.keepNativeChatGptOnV1 = true;
220
- else delete cfg.keepNativeChatGptOnV1;
237
+ else deleteConfigTopLevelKey(cfg, "keepNativeChatGptOnV1");
221
238
  saveConfig(cfg);
222
239
  try {
223
240
  const sync = deps.sync ?? (await import("../codex/sync")).syncModelsToCodex;
@@ -243,6 +260,13 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
243
260
  }
244
261
 
245
262
  const want = verb === "on";
263
+ if (want) {
264
+ const cfg = loadConfig();
265
+ if (requiresGlobalV2Disabled(cfg.multiAgentMode, cfg.keepNativeChatGptOnV1 === true)) {
266
+ log.error("v2 on: incompatible with keep-native-v1 while mode is v2 — Codex's global multi_agent_v2 overrides the native v1 catalog pin. Run 'ocx v2 keep-native-v1 off' first.");
267
+ return 1;
268
+ }
269
+ }
246
270
  const transition = transitionMultiAgentV2(want, enabled => runCodexFeatures(enabled ? "enable" : "disable", deps));
247
271
  if (!transition.ok) {
248
272
  log.error(`codex features ${want ? "enable" : "disable"} multi_agent_v2 failed: ${transition.error}`);
@@ -1,4 +1,5 @@
1
1
  import type { OcxConfig } from "../types";
2
+ import { deleteConfigTopLevelKey } from "../config/rebase-provenance";
2
3
 
3
4
  /** Whether an account is administratively excluded from future pool selection. */
4
5
  export function isCodexAccountPaused(config: OcxConfig, accountId: string): boolean {
@@ -12,7 +13,7 @@ export function setCodexAccountPaused(config: OcxConfig, accountId: string, paus
12
13
  else pausedIds.delete(accountId);
13
14
 
14
15
  if (pausedIds.size > 0) config.pausedCodexAccountIds = [...pausedIds];
15
- else delete config.pausedCodexAccountIds;
16
+ else deleteConfigTopLevelKey(config, "pausedCodexAccountIds");
16
17
  }
17
18
 
18
19
  export function forgetCodexAccountPause(config: OcxConfig, accountId: string): void {
@@ -1,6 +1,7 @@
1
1
  import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id";
2
2
  import { DEFAULT_ACCOUNT_PRIORITY, normalizeAccountPriority } from "./pool-rotation";
3
3
  import type { OcxConfig } from "../types";
4
+ import { deleteConfigTopLevelKey } from "../config/rebase-provenance";
4
5
 
5
6
  /**
6
7
  * Which ids may carry a selection order: any pool account, plus the synthetic
@@ -34,7 +35,7 @@ export function setCodexAccountPriority(config: OcxConfig, accountId: string, pr
34
35
  else entries.set(accountId, priority);
35
36
 
36
37
  if (entries.size > 0) config.codexAccountPriorities = Object.fromEntries(entries);
37
- else delete config.codexAccountPriorities;
38
+ else deleteConfigTopLevelKey(config, "codexAccountPriorities");
38
39
  }
39
40
 
40
41
  export function forgetCodexAccountPriority(config: OcxConfig, accountId: string): void {
@@ -78,6 +79,6 @@ export function setCodexAccountPin(config: OcxConfig, accountId: string): void {
78
79
  /** Release the pin. With `accountId` given, only when it is the pinned account. */
79
80
  export function clearCodexAccountPin(config: OcxConfig, accountId?: string): void {
80
81
  if (accountId === undefined || config.activeCodexAccountPinned === accountId) {
81
- delete config.activeCodexAccountPinned;
82
+ deleteConfigTopLevelKey(config, "activeCodexAccountPinned");
82
83
  }
83
84
  }
@@ -260,6 +260,23 @@ export function isCodexAppServerCommandLine(commandLine: string, executable?: st
260
260
  }
261
261
  if (tokens.length === 0) return false;
262
262
  if (isCodeModeHostProcess(tokens)) return true;
263
+ // An npm-installed Codex runs as a PAIR: `node /usr/local/bin/codex app-server` and the
264
+ // vendored native binary that wrapper spawns. Only the child used to match, so
265
+ // `--restart-codex` signalled the child while its supervisor kept holding the socket -
266
+ // which is what "PID(s) still running after SIGTERM" was reporting on Linux.
267
+ //
268
+ // The codex-shaped token must be IMMEDIATELY next. Skipping interpreter flags to reach
269
+ // it looks tempting and is wrong: interpreter options take values, so a generic skip
270
+ // reads the value of `node --require codex app-server worker.js` as the entrypoint.
271
+ // Supporting flags needs a real Node/Bun/Deno entrypoint parser, not a loop over
272
+ // hyphens; the observed wrappers put the path first, so this stays narrow on purpose.
273
+ //
274
+ // Dropping only the interpreter and re-running the ordinary scan is what preserves the
275
+ // subcommand discipline below: `node <codex> exec 'hi'` stays unmatched exactly like
276
+ // `codex exec 'hi'` does.
277
+ if (isInterpreterToken(tokens[0]!) && tokens.length > 1 && isCodexExecutableToken(tokens[1]!)) {
278
+ tokens = tokens.slice(1);
279
+ }
263
280
  if (!isCodexExecutableToken(tokens[0]!)) return false;
264
281
 
265
282
  let i = 1;
@@ -740,6 +757,28 @@ const CATALOG_STATE_TTL_MS = 5_000;
740
757
  */
741
758
  const CATALOG_STATE_UNKNOWN_TTL_MS = 250;
742
759
 
760
+ /**
761
+ * How long a real observation may still be SERVED after it expires, while a refresh
762
+ * runs behind it (#2499).
763
+ *
764
+ * The probe is advisory and, on Windows, slow: `Invoke-CimMethod GetOwner` costs
765
+ * ~0.4s per candidate process, so a cold probe routinely outlives the 5s TTL and
766
+ * every turn that misses the cache pays for it on the request path. Serving the
767
+ * previous reading immediately keeps that cost off the turn without pretending it is
768
+ * fresh -- the refresh it triggers is what makes the next reading current.
769
+ *
770
+ * Measured from expiry rather than from when the reading was taken, so a `fresh`
771
+ * entry stays servable for its own TTL plus this bound. Anchoring it to expiry keeps
772
+ * the stale window independent of the TTL -- a cap on total age would quietly turn
773
+ * this path off if the TTL were ever raised past it.
774
+ *
775
+ * Bounded rather than unlimited: if the refresh keeps failing, an observation this
776
+ * old stops being evidence about the machine and it is better to wait for a real one.
777
+ * `unknown` is never served this way -- it is a failure to observe, not an
778
+ * observation, and it already has its own short window for exactly that reason.
779
+ */
780
+ export const CATALOG_STATE_MAX_STALE_MS = 60_000;
781
+
743
782
  export function catalogStateTtlMs(state: CodexAppServerCatalogState): number {
744
783
  return state === "unknown" ? CATALOG_STATE_UNKNOWN_TTL_MS : CATALOG_STATE_TTL_MS;
745
784
  }
@@ -835,6 +874,14 @@ export function collectCodexAppServerCatalogState(
835
874
  * - 선택한 방식: retain the synchronous API and use async PowerShell plus an identity-scoped in-flight refresh, short cache, and invalidation generation only for Windows requests.
836
875
  * - 다른 대안 대신 이 방식을 선택한 이유: it fixes unrelated `/healthz` starvation without widening the process-matching or restart contract.
837
876
  * - 장점, 단점 및 영향: concurrent turns share one CIM walk, invalidated pre-write results cannot repopulate the cache, and the event loop stays responsive; a cold v2 turn can still await the bounded advisory probe.
877
+ *
878
+ * [Decision Log · #2499]
879
+ * - 목적과 의도: a cold probe outlives its own 5s TTL on Windows (~435ms per candidate process for `Invoke-CimMethod GetOwner`), so the cache expires before it can serve and the miss lands on a turn.
880
+ * - 기존 구현 및 제약 조건: the reading is advisory, and only `fresh` authorizes positive guidance (`src/server/responses/collaboration.ts`); `unknown` is a failure to observe rather than an observation.
881
+ * - 검토한 주요 대안: drop the per-process GetOwner fan-out (issue suggestion 1), or widen the TTL past the probe duration (suggestion 3).
882
+ * - 선택한 방식: serve an expired reading immediately when its generation still matches, bounded by `CATALOG_STATE_MAX_STALE_MS`, never for `unknown`, and refresh behind it; a failed refresh no longer evicts the reading it was refreshing.
883
+ * - 다른 대안 대신 이 방식을 선택한 이유: the fan-out change alters what "could not verify the owner" means for the current-user scoping contract and needs its own ground-truth comparison; a wider TTL still pays the probe on every human-paced turn.
884
+ * - 장점, 단점 및 영향: after the first probe the request path never waits; a server that stopped between readings can be described as `fresh` for up to the stale bound; a catalog write still invalidates immediately through the generation, and the cold path is unchanged.
838
885
  */
839
886
  export async function collectCodexAppServerCatalogStateForRequest(
840
887
  io: CodexAppServerProcessIo = {},
@@ -853,16 +900,30 @@ export async function collectCodexAppServerCatalogStateForRequest(
853
900
  catalogMtimeMs: io.catalogMtimeMs,
854
901
  now: io.now,
855
902
  };
856
- if (requestCatalogStateCache
903
+ const cached = requestCatalogStateCache
857
904
  && requestCatalogStateCache.generation === generation
858
905
  && sameRequestCatalogStateIdentity(requestCatalogStateCache.identity, identity)
859
- && now - requestCatalogStateCache.atMs < catalogStateTtlMs(requestCatalogStateCache.status.state)) {
860
- return requestCatalogStateCache.status;
906
+ ? requestCatalogStateCache
907
+ : null;
908
+ if (cached && now - cached.atMs < catalogStateTtlMs(cached.status.state)) {
909
+ return cached.status;
861
910
  }
911
+ // An expired real reading is still worth handing back while the refresh runs. It
912
+ // cannot have been invalidated by an ocx catalog write: every such write calls
913
+ // `resetCodexAppServerCatalogStateCache`, which advances the generation and drops
914
+ // this entry, so a generation match means no write has landed since it was taken.
915
+ // What it can miss is an app-server that started or stopped meanwhile -- and a
916
+ // server started after the reading is newer than the catalog, which is the `fresh`
917
+ // this entry already says.
918
+ const servableStale = cached
919
+ && cached.status.state !== "unknown"
920
+ && now - cached.atMs < catalogStateTtlMs(cached.status.state) + CATALOG_STATE_MAX_STALE_MS
921
+ ? cached.status
922
+ : null;
862
923
  if (requestCatalogStateFlight
863
924
  && requestCatalogStateFlight.generation === generation
864
925
  && sameRequestCatalogStateIdentity(requestCatalogStateFlight.identity, identity)) {
865
- return requestCatalogStateFlight.promise;
926
+ return servableStale ?? requestCatalogStateFlight.promise;
866
927
  }
867
928
 
868
929
  const refresh = async (): Promise<CodexAppServerCatalogStatus> => {
@@ -920,7 +981,18 @@ export async function collectCodexAppServerCatalogStateForRequest(
920
981
  if (requestCatalogStateGeneration !== generation) {
921
982
  return { state: "unknown" as const, processes: [], catalogMtimeMs: null };
922
983
  }
923
- if (requestCatalogStateFlight === flight) {
984
+ // A refresh that failed must not evict a real reading. Before this function
985
+ // served stale entries, caching `unknown` cost at most the 250ms that state
986
+ // is allowed to live. Now that an expired observation is what callers are
987
+ // handed, overwriting one with `unknown` would take the answer AWAY from
988
+ // them on a transient failure -- `unknown` is not servable, so the next
989
+ // caller waits for a probe instead of getting the reading it would have had.
990
+ // Keep the observation and let its own age retire it.
991
+ const wouldEvictAnObservation = status.state === "unknown"
992
+ && requestCatalogStateCache?.generation === generation
993
+ && requestCatalogStateCache.status.state !== "unknown"
994
+ && sameRequestCatalogStateIdentity(requestCatalogStateCache.identity, identity);
995
+ if (requestCatalogStateFlight === flight && !wouldEvictAnObservation) {
924
996
  requestCatalogStateCache = {
925
997
  generation,
926
998
  identity,
@@ -934,7 +1006,9 @@ export async function collectCodexAppServerCatalogStateForRequest(
934
1006
  });
935
1007
  flight = { generation, identity, promise };
936
1008
  requestCatalogStateFlight = flight;
937
- return flight.promise;
1009
+ // `promise` already absorbs its own failures, so leaving it unawaited here cannot
1010
+ // surface as an unhandled rejection; the next caller picks up whatever it stored.
1011
+ return servableStale ?? flight.promise;
938
1012
  }
939
1013
 
940
1014
  /**
@@ -209,21 +209,61 @@ function codexAccountPersistenceConflict(
209
209
  : undefined;
210
210
  }
211
211
 
212
+ /**
213
+ * The exact label `parseUsageQuota` emits for the Codex Spark window (quota.ts).
214
+ * Matching on the label rather than on "is a custom window" is load-bearing: the same array
215
+ * carries Cursor's First-party models / API usage, Anthropic's Fable / Opus / Sonnet,
216
+ * Antigravity's Gem / Cla, Kimi's subscription credits and a dozen dynamic provider meters.
217
+ */
218
+ const CODEX_SPARK_WINDOW_LABEL = "GPT-5.3-Codex-Spark Weekly";
219
+
220
+ /**
221
+ * Drop the Spark window unless the operator asked for it (default hidden).
222
+ *
223
+ * Applied at the DTO boundary, never at parse or cache time: custom windows participate in
224
+ * quota-presence checks, snapshot reconciliation and capacity aggregation, so removing Spark
225
+ * upstream of this point would change routing state rather than display.
226
+ *
227
+ * Both GUI surfaces funnel through here — the Codex Auth rows directly, and /api/provider-quotas
228
+ * via listCodexAuthAccountsSnapshot — so one filter covers both. Filtering only one would leave
229
+ * the other still rendering the row the operator switched off.
230
+ */
231
+ export function withSparkVisibility<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
232
+ quota: T,
233
+ ): T {
234
+ if (!quota?.customWindows?.length) return quota;
235
+ if (loadConfig().showCodexSparkQuota === true) return quota;
236
+ const kept = quota.customWindows.filter(window => window.label !== CODEX_SPARK_WINDOW_LABEL);
237
+ if (kept.length === quota.customWindows.length) return quota;
238
+ // An empty list is dropped rather than serialized: an absent field and an empty array should
239
+ // not be two different ways of saying "no custom windows" on the wire.
240
+ const next = { ...quota } as Record<string, unknown>;
241
+ if (kept.length > 0) next.customWindows = kept;
242
+ else delete next.customWindows;
243
+ return next as T;
244
+ }
245
+
246
+
212
247
  function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
213
248
  quota: T,
214
249
  plan: unknown,
215
250
  ): T {
216
- if (!quota || !isThirtyDayOnlyCodexPlan(plan)) return quota;
251
+ const visible = withSparkVisibility(quota);
252
+ if (!visible || !isThirtyDayOnlyCodexPlan(plan)) return visible;
253
+ const quotaWindows = visible;
217
254
  return {
218
- ...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}),
219
- ...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}),
255
+ ...(quotaWindows.monthlyPercent !== undefined ? { monthlyPercent: quotaWindows.monthlyPercent } : {}),
256
+ ...(quotaWindows.monthlyResetAt !== undefined ? { monthlyResetAt: quotaWindows.monthlyResetAt } : {}),
220
257
  // A 30-day plan can still carry a burst window, and it blocks the account on its own.
221
258
  // Dropping it here would show a healthy card for an account upstream is refusing (#1791).
222
- ...(quota.shortPercent !== undefined ? { shortPercent: quota.shortPercent } : {}),
223
- ...(quota.shortResetAt !== undefined ? { shortResetAt: quota.shortResetAt } : {}),
224
- ...(quota.shortWindowSeconds !== undefined ? { shortWindowSeconds: quota.shortWindowSeconds } : {}),
225
- ...(quota.resetCredits !== undefined ? { resetCredits: quota.resetCredits } : {}),
226
- ...("updatedAt" in quota ? { updatedAt: quota.updatedAt } : {}),
259
+ ...(quotaWindows.fiveHourPercent !== undefined ? { fiveHourPercent: quotaWindows.fiveHourPercent } : {}),
260
+ ...(quotaWindows.fiveHourResetAt !== undefined ? { fiveHourResetAt: quotaWindows.fiveHourResetAt } : {}),
261
+ ...(quotaWindows.shortPercent !== undefined ? { shortPercent: quotaWindows.shortPercent } : {}),
262
+ ...(quotaWindows.shortResetAt !== undefined ? { shortResetAt: quotaWindows.shortResetAt } : {}),
263
+ ...(quotaWindows.shortWindowSeconds !== undefined ? { shortWindowSeconds: quotaWindows.shortWindowSeconds } : {}),
264
+ ...(quotaWindows.customWindows !== undefined ? { customWindows: quotaWindows.customWindows } : {}),
265
+ ...(quotaWindows.resetCredits !== undefined ? { resetCredits: quotaWindows.resetCredits } : {}),
266
+ ...("updatedAt" in quotaWindows ? { updatedAt: quotaWindows.updatedAt } : {}),
227
267
  } as T;
228
268
  }
229
269
 
@@ -29,7 +29,6 @@ import {
29
29
  entitledCodexAccountIdsForModel,
30
30
  isDirectCallerEntitledToCodexModel,
31
31
  resolveCodexModelEntitlements,
32
- type CodexModelEntitlementSnapshot,
33
32
  } from "./model-entitlements";
34
33
  import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
35
34
  import type { CodexCooldownSource, CodexQuotaScope } from "./routing";
@@ -334,9 +333,7 @@ export interface ResolveCodexAuthContextOptions {
334
333
  getMainAccountToken?: typeof getMainAccountToken;
335
334
  primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise<void>;
336
335
  /** Test seam for account-gated native model discovery. */
337
- resolveCodexModelEntitlements?: (
338
- config: Pick<OcxConfig, "codexAccounts">,
339
- ) => Promise<CodexModelEntitlementSnapshot>;
336
+ resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements;
340
337
  /** Direct requests admitted with a proxy bearer substitute the stored native-main credential. */
341
338
  substituteMainCredentialForDirect?: boolean;
342
339
  /** Test seam for a Direct request's own forwarded ChatGPT credential. */
@@ -381,28 +378,34 @@ export async function resolveCodexAuthContext(
381
378
  return { kind: "main", accountId: null };
382
379
  }
383
380
  const affinityKey = fixedAccountId === undefined ? codexPoolAffinityKey(headers) : undefined;
384
- const entitlementSnapshot = options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)
385
- ? await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config)
386
- : undefined;
387
- const modelEligibleAccountIds = entitlementSnapshot
388
- ? entitledCodexAccountIdsForModel(entitlementSnapshot, options.modelId)
389
- : undefined;
390
381
  // Retained startup recovery makes the physical main identity ineligible. Routing
391
382
  // can still preserve service by selecting a healthy configured pool account.
392
383
  const nativeMainTrafficBlocked = isNativeMainTrafficBlocked();
393
384
  const selectionAdmission = options.beginCodexAccountSelection?.();
394
385
  const nativeMainReadsForbidden = nativeMainTrafficBlocked || selectionAdmission?.mainProfileDraining === true;
395
- const selectionOptions = {
396
- // Temporary switch drain keeps the candidate until the atomic claim rejects
397
- // it. Retained recovery makes main wholly ineligible so pool routing continues.
398
- nativeMainSelectionOnly: !nativeMainTrafficBlocked
399
- && selectionAdmission?.mainProfileDraining === true,
400
- isMainAccountTokenLive: options.isMainAccountTokenLive,
401
- modelEligibleAccountIds,
402
- };
403
386
  let accountId: string;
404
387
  const quotaScope = codexQuotaScopeForModel(options.modelId);
405
388
  try {
389
+ const excludeAccountIds = nativeMainReadsForbidden
390
+ ? new Set([MAIN_CODEX_ACCOUNT_ID])
391
+ : undefined;
392
+ const entitlementSnapshot = options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)
393
+ ? await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config, { excludeAccountIds })
394
+ : undefined;
395
+ const entitledAccountIds = entitlementSnapshot
396
+ ? entitledCodexAccountIdsForModel(entitlementSnapshot, options.modelId)
397
+ : undefined;
398
+ const modelEligibleAccountIds = entitledAccountIds
399
+ ? new Set([...entitledAccountIds].filter(candidate => !excludeAccountIds?.has(candidate)))
400
+ : undefined;
401
+ const selectionOptions = {
402
+ // Temporary switch drain keeps the candidate until the atomic claim rejects
403
+ // it. Retained recovery makes main wholly ineligible so pool routing continues.
404
+ nativeMainSelectionOnly: !nativeMainTrafficBlocked
405
+ && selectionAdmission?.mainProfileDraining === true,
406
+ isMainAccountTokenLive: options.isMainAccountTokenLive,
407
+ modelEligibleAccountIds,
408
+ };
406
409
  // A pre-drain selector reserves the native identity while reconciliation and
407
410
  // routing inspect it. Selectors arriving after the fence skip reconciliation
408
411
  // and may still route to non-main pool accounts without touching switch state.