@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
@@ -9,13 +9,16 @@ import { isCodexAccountUsable } from "./account-usability";
9
9
  import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
10
10
  import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account";
11
11
  import {
12
- getCodexAccountHealthSnapshot,
12
+ codexQuotaScopeForModel,
13
+ getCodexQuotaHealthSnapshot,
13
14
  releaseCodexQuotaProbeLease,
15
+ releaseCodexQuotaScopeProbeLease,
14
16
  tryAcquireCodexQuotaProbeLease,
15
- pickLowestUsageCodexAccount,
17
+ tryAcquireCodexQuotaScopeProbeLease,
18
+ pickAlternateCodexAccount,
16
19
  resolveCodexAccountForThreadDetailed,
17
20
  } from "./routing";
18
- import type { CodexCooldownSource } from "./routing";
21
+ import type { CodexCooldownSource, CodexQuotaScope } from "./routing";
19
22
  import { maskAccountId } from "../lib/privacy";
20
23
  import { formatErrorResponse } from "../bridge";
21
24
  import { getAccountQuota } from "./quota";
@@ -34,8 +37,12 @@ export type CodexAuthContext =
34
37
  * Set when this request was admitted through an active quota cooldown as
35
38
  * the account's single probe. Must be echoed into the upstream outcome so
36
39
  * only this request can clear the cooldown (#433).
37
- */
40
+ */
38
41
  probeLeaseId?: string;
42
+ /** Native model quota group selected for this request, when known. */
43
+ quotaScope?: CodexQuotaScope;
44
+ /** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */
45
+ probeQuotaScope?: CodexQuotaScope;
39
46
  }
40
47
  | {
41
48
  // Main Codex account participating in rotation: token injected from ~/.codex/auth.json
@@ -46,6 +53,8 @@ export type CodexAuthContext =
46
53
  chatgptAccountId: string;
47
54
  /** See `pool.probeLeaseId`. */
48
55
  probeLeaseId?: string;
56
+ quotaScope?: CodexQuotaScope;
57
+ probeQuotaScope?: CodexQuotaScope;
49
58
  };
50
59
 
51
60
  /** Probe lease carried by this context, when it holds one. */
@@ -53,13 +62,20 @@ export function codexProbeLeaseId(ctx: CodexAuthContext | undefined): string | u
53
62
  return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeLeaseId : undefined;
54
63
  }
55
64
 
65
+ /** Scope of a lease carried by this context, when it probes a model-specific quota. */
66
+ export function codexProbeQuotaScope(ctx: CodexAuthContext | undefined): CodexQuotaScope | undefined {
67
+ return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeQuotaScope : undefined;
68
+ }
69
+
56
70
  /**
57
71
  * Hand back a probe lease for a request that will not reach upstream. Safe to
58
72
  * call with a context that holds no lease.
59
73
  */
60
74
  export function releaseCodexAuthContextProbeLease(ctx: CodexAuthContext | undefined): void {
61
75
  const leaseId = codexProbeLeaseId(ctx);
62
- if (ctx && leaseId) releaseCodexQuotaProbeLease(ctx.accountId!, leaseId);
76
+ if (!ctx || ctx.kind === "main" || !leaseId) return;
77
+ if (ctx.probeQuotaScope) releaseCodexQuotaScopeProbeLease(ctx.accountId!, ctx.probeQuotaScope, leaseId);
78
+ else releaseCodexQuotaProbeLease(ctx.accountId!, leaseId);
63
79
  }
64
80
 
65
81
  export type OcxRuntimeProviderConfig = OcxProviderConfig & {
@@ -99,13 +115,20 @@ export class CodexAccountCooldownError extends Error {
99
115
  accountId: string;
100
116
  cooldownUntil: number;
101
117
  cooldownSource?: CodexCooldownSource;
118
+ quotaScope?: CodexQuotaScope;
102
119
 
103
- constructor(accountId: string, cooldownUntil: number, cooldownSource?: CodexCooldownSource) {
120
+ constructor(
121
+ accountId: string,
122
+ cooldownUntil: number,
123
+ cooldownSource?: CodexCooldownSource,
124
+ quotaScope?: CodexQuotaScope,
125
+ ) {
104
126
  super("Selected Codex account is cooling down");
105
127
  this.name = "CodexAccountCooldownError";
106
128
  this.accountId = accountId;
107
129
  this.cooldownUntil = cooldownUntil;
108
130
  this.cooldownSource = cooldownSource;
131
+ this.quotaScope = quotaScope;
109
132
  }
110
133
  }
111
134
 
@@ -127,7 +150,12 @@ export function cooldownAccountLabel(accountId: string): string {
127
150
  */
128
151
  export function cooldownErrorMessage(err: CodexAccountCooldownError): string {
129
152
  const until = new Date(err.cooldownUntil).toISOString();
130
- return `Selected Codex account (${cooldownAccountLabel(err.accountId)}) is cooling down until ${until}`
153
+ const scope = err.quotaScope === "spark"
154
+ ? "Spark quota"
155
+ : err.quotaScope === "shared"
156
+ ? "shared native quota"
157
+ : null;
158
+ return `Selected Codex account (${cooldownAccountLabel(err.accountId)})${scope ? ` ${scope} is` : " is"} cooling down until ${until}`
131
159
  + ` (source: ${err.cooldownSource ?? "default"}).`
132
160
  + ` Run 'ocx account list openai' to find the id, then`
133
161
  + ` 'ocx account clear-cooldown openai <id>' to lift it, or switch accounts with 'ocx account use openai <id>'.`;
@@ -157,6 +185,8 @@ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown):
157
185
 
158
186
  export interface ResolveCodexAuthContextOptions {
159
187
  excludeAccountId?: string;
188
+ /** Final native model selected for this request, used to select its quota group. */
189
+ modelId?: string;
160
190
  }
161
191
 
162
192
  export async function resolveCodexAuthContext(
@@ -171,16 +201,17 @@ export async function resolveCodexAuthContext(
171
201
  }
172
202
  reconcileMainCodexAccountRuntimeState();
173
203
  const threadId = headers.get("x-codex-parent-thread-id");
204
+ const quotaScope = codexQuotaScopeForModel(options.modelId);
174
205
  const resolution = options.excludeAccountId
175
206
  ? (() => {
176
- const accountId = pickLowestUsageCodexAccount(config, options.excludeAccountId);
207
+ const accountId = pickAlternateCodexAccount(config, options.excludeAccountId!, Date.now(), quotaScope);
177
208
  return accountId
178
209
  ? { status: "selected" as const, accountId }
179
210
  : { status: "none" as const };
180
211
  })()
181
- : resolveCodexAccountForThreadDetailed(threadId, config);
212
+ : resolveCodexAccountForThreadDetailed(threadId, config, Date.now(), quotaScope);
182
213
  if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
183
- const accountId = resolution.status === "selected" ? resolution.accountId : null;
214
+ let accountId = resolution.status === "selected" ? resolution.accountId : null;
184
215
  if (!accountId) throw new CodexPoolAuthenticationError();
185
216
  // Lazy prime: if the selected account has no quota yet, the pool is likely
186
217
  // unprimed (dashboard never opened, or startup prime was blocked). Kick a
@@ -194,15 +225,21 @@ export async function resolveCodexAuthContext(
194
225
  }
195
226
  // Snapshot (not just the deadline) so a refused request can report WHY it is cooled:
196
227
  // a literal Retry-After reads very differently to a user than a reset-derived guess.
197
- const cooldown = getCodexAccountHealthSnapshot(accountId);
228
+ const cooldown = getCodexQuotaHealthSnapshot(accountId, quotaScope);
198
229
  const cooldownUntil = cooldown?.cooldownUntil;
199
230
  // A cooled-down account never sends traffic, so upstream recovery can never be
200
231
  // observed and the cooldown outlives the real limit. Admit one probe per
201
232
  // interval; its outcome decides whether the cooldown ends (#433).
202
233
  let probeLeaseId: string | undefined;
234
+ let probeQuotaScope: CodexQuotaScope | undefined;
203
235
  if (cooldownUntil) {
204
- probeLeaseId = tryAcquireCodexQuotaProbeLease(accountId) ?? undefined;
205
- if (!probeLeaseId) throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource);
236
+ probeQuotaScope = cooldown?.quotaScope;
237
+ probeLeaseId = probeQuotaScope
238
+ ? tryAcquireCodexQuotaScopeProbeLease(accountId, probeQuotaScope) ?? undefined
239
+ : tryAcquireCodexQuotaProbeLease(accountId) ?? undefined;
240
+ if (!probeLeaseId) {
241
+ throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope);
242
+ }
206
243
  }
207
244
 
208
245
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
@@ -210,7 +247,8 @@ export async function resolveCodexAuthContext(
210
247
  const token = getMainAccountToken();
211
248
  if (!token) {
212
249
  // Nothing will reach upstream, so give the probe back instead of burning it.
213
- if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
250
+ if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
251
+ else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
214
252
  throw new CodexPoolAuthenticationError();
215
253
  }
216
254
  return {
@@ -218,7 +256,9 @@ export async function resolveCodexAuthContext(
218
256
  accountId,
219
257
  accessToken: token.accessToken,
220
258
  chatgptAccountId: token.chatgptAccountId,
259
+ ...(quotaScope ? { quotaScope } : {}),
221
260
  ...(probeLeaseId ? { probeLeaseId } : {}),
261
+ ...(probeQuotaScope ? { probeQuotaScope } : {}),
222
262
  };
223
263
  }
224
264
 
@@ -230,10 +270,13 @@ export async function resolveCodexAuthContext(
230
270
  generation: token.generation,
231
271
  accessToken: token.accessToken,
232
272
  chatgptAccountId: token.chatgptAccountId,
273
+ ...(quotaScope ? { quotaScope } : {}),
233
274
  ...(probeLeaseId ? { probeLeaseId } : {}),
275
+ ...(probeQuotaScope ? { probeQuotaScope } : {}),
234
276
  };
235
277
  } catch (cause) {
236
- if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
278
+ if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
279
+ else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
237
280
  if (shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
238
281
  markAccountNeedsReauth(accountId);
239
282
  }
@@ -245,9 +288,9 @@ export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefine
245
288
  if (ctx?.kind !== "pool" && ctx?.kind !== "main-pool") return;
246
289
  // A context holding the probe lease was deliberately admitted through the cooldown.
247
290
  if (ctx.probeLeaseId) return;
248
- const cooldown = getCodexAccountHealthSnapshot(ctx.accountId);
291
+ const cooldown = getCodexQuotaHealthSnapshot(ctx.accountId, ctx.quotaScope);
249
292
  if (cooldown?.cooldownUntil) {
250
- throw new CodexAccountCooldownError(ctx.accountId, cooldown.cooldownUntil, cooldown.cooldownSource);
293
+ throw new CodexAccountCooldownError(ctx.accountId, cooldown.cooldownUntil, cooldown.cooldownSource, cooldown.quotaScope);
251
294
  }
252
295
  }
253
296
 
@@ -141,9 +141,24 @@ export function isMediaGenerationModelId(id: string): boolean {
141
141
  return MEDIA_GEN_ID_RE.test(id);
142
142
  }
143
143
 
144
+ /**
145
+ * Gemini image-capable chat models produce inline images within text responses
146
+ * via the Responses API. Explicit allowlist only — a broad `/gemini/ && /image/`
147
+ * heuristic resurrects standalone media-gen IDs (e.g. gemini-3-pro-image).
148
+ */
149
+ const GEMINI_IMAGE_CHAT_MODEL_IDS = new Set([
150
+ "gemini-3.1-flash-image",
151
+ "gemini-2.0-flash-preview-image-generation",
152
+ "gemini-3-pro-image-preview",
153
+ ]);
154
+
155
+ function isGeminiImageChatModel(id: string): boolean {
156
+ return GEMINI_IMAGE_CHAT_MODEL_IDS.has(id);
157
+ }
158
+
144
159
  export function shouldExposeRoutedModel(model: CatalogModel): boolean {
145
160
  if (isRoutedModelCompatibilityExcluded(`${model.provider}/${model.id}`)) return false;
146
- if (model.provider === "cursor" && model.id === "gemini-3-pro-image-preview") return true;
161
+ if (isGeminiImageChatModel(model.id)) return true;
147
162
  return !isMediaGenerationModelId(model.id);
148
163
  }
149
164
 
@@ -39,7 +39,11 @@ import {
39
39
  targetKey,
40
40
  } from "../../combos";
41
41
  import type { NormalizedComboConfig } from "../../combos/types";
42
- import { providerDestinationResolvedError } from "../../lib/destination-policy";
42
+ import {
43
+ ProviderOutboundPolicyError,
44
+ providerOutboundGet,
45
+ providerRedirectError,
46
+ } from "../../lib/provider-outbound";
43
47
  import { redactSecretString } from "../../lib/redact";
44
48
  import upstreamModelsSnapshot from "../data/upstream-models.json";
45
49
 
@@ -71,6 +75,32 @@ export function isProviderModelsApiItems(value: unknown): value is ProviderModel
71
75
  );
72
76
  }
73
77
 
78
+ /**
79
+ * Normalize OpenAI-compatible /models payloads for catalog discovery.
80
+ * Supports `{ data: [...] }` and top-level arrays (Together AI `#617`).
81
+ * Google's `{ models: [...] }` is handled by the connectivity probe only — catalog
82
+ * discovery must not treat a stray `models` key on openai-chat responses as valid.
83
+ */
84
+ export function providerModelsListFromResponse(json: unknown): unknown {
85
+ if (Array.isArray(json)) return json;
86
+ if (json !== null && typeof json === "object" && !Array.isArray(json)) {
87
+ const data = (json as { data?: unknown }).data;
88
+ if (Array.isArray(data)) return data;
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ /** Connectivity-probe shape: also accepts Google `{ models: [...] }`. */
94
+ export function providerModelsListFromProbeResponse(json: unknown): unknown {
95
+ if (Array.isArray(json)) return json;
96
+ if (json !== null && typeof json === "object" && !Array.isArray(json)) {
97
+ const obj = json as { data?: unknown; models?: unknown };
98
+ if (Array.isArray(obj.data)) return obj.data;
99
+ if (Array.isArray(obj.models)) return obj.models;
100
+ }
101
+ return undefined;
102
+ }
103
+
74
104
  export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined {
75
105
  const configured = modelRecordValue(prov.modelContextWindows, id) ?? prov.contextWindow;
76
106
  return typeof configured === "number" && configured > 0 ? configured : undefined;
@@ -322,21 +352,20 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
322
352
  };
323
353
  };
324
354
  try {
325
- const destinationError = await providerDestinationResolvedError(name, {
326
- baseUrl: url,
327
- allowPrivateNetwork: prov.allowPrivateNetwork,
355
+ const res = await providerOutboundGet(name, prov, url, {
356
+ headers,
357
+ signal: AbortSignal.timeout(8000),
328
358
  });
329
- if (destinationError) {
330
- const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "blocked" });
359
+ const redirectError = await providerRedirectError(res, url);
360
+ if (redirectError) {
361
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
331
362
  if (shouldLog) {
332
363
  console.warn(
333
- `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${destinationError} [urlClass=${urlClass}, fallback=${fallback}].`,
364
+ `[opencodex] Provider model discovery for "${name}" ${redirectError} [urlClass=${urlClass}, fallback=${fallback}].`,
334
365
  );
335
366
  }
336
367
  return models;
337
368
  }
338
-
339
- const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
340
369
  if (!res.ok) {
341
370
  const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
342
371
  if (shouldLog) {
@@ -366,9 +395,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
366
395
  }
367
396
  return models;
368
397
  }
369
- const data = json !== null && typeof json === "object" && !Array.isArray(json)
370
- ? (json as { data?: unknown }).data
371
- : undefined;
398
+ const data = providerModelsListFromResponse(json);
372
399
  if (!isProviderModelsApiItems(data)) {
373
400
  const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
374
401
  if (shouldLog) {
@@ -421,6 +448,15 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
421
448
  setCached(name, live);
422
449
  return live;
423
450
  } catch (error) {
451
+ if (error instanceof ProviderOutboundPolicyError) {
452
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "blocked" });
453
+ if (shouldLog) {
454
+ console.warn(
455
+ `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${error.message} [urlClass=${urlClass}, fallback=${fallback}].`,
456
+ );
457
+ }
458
+ return models;
459
+ }
424
460
  const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "network" });
425
461
  if (shouldLog) {
426
462
  console.warn(
@@ -329,9 +329,11 @@ export function mergeCatalogEntriesForSync(
329
329
  multiAgentMode: MultiAgentMode = "default",
330
330
  exactComboSlugs: ReadonlySet<string> = new Set(),
331
331
  hasPhysicalComboProvider = false,
332
+ includeNativeOpenAi = true,
332
333
  ): RawEntry[] {
333
334
  const rank = new Map(featured.map((slug, i) => [slug, i] as const));
334
- const native = catalogModels
335
+ const native = includeNativeOpenAi
336
+ ? catalogModels
335
337
  .filter(m => typeof m.slug === "string"
336
338
  && !(m.slug as string).includes("/")
337
339
  && m.owned_by !== COMBO_NAMESPACE
@@ -367,11 +369,14 @@ export function mergeCatalogEntriesForSync(
367
369
  // for subagent max spawns; wire-clamped to the model's real top rung).
368
370
  if (!isGpt56NativeSlug(slug)) ensureUltraReasoningLevel(preserved);
369
371
  return preserved;
370
- });
372
+ })
373
+ : [];
371
374
 
372
375
  // Backfill any native OpenAI slug that the on-disk catalog is missing (e.g. gpt-5.5), so a
373
376
  // routed provider exposing the same id can never delete the native OpenAI/Codex base row.
377
+ // Skip when no enabled canonical openai provider exists (#636) — bare gpt-* would 404.
374
378
  const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
379
+ if (includeNativeOpenAi) {
375
380
  for (const slug of nativeOpenAiSlugs()) {
376
381
  if (nativeSlugs.has(slug)) continue;
377
382
  nativeSlugs.add(slug);
@@ -382,6 +387,7 @@ export function mergeCatalogEntriesForSync(
382
387
  : 9;
383
388
  native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority));
384
389
  }
390
+ }
385
391
 
386
392
  const freshSlugs = new Set(
387
393
  routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []),
@@ -497,7 +503,16 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{
497
503
  // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a
498
504
  // native template can never leak supports_websockets while the flag is off.
499
505
  const wsEnabled = websocketsEnabled(config);
500
- catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider);
506
+ const enabledProviders = Object.entries(config.providers ?? {})
507
+ .filter(([, prov]) => prov.disabled !== true);
508
+ const hasCanonicalOpenai = enabledProviders.some(([name, prov]) =>
509
+ name === "openai" && isCanonicalOpenAiForwardProvider(prov),
510
+ );
511
+ // #636: when the user only configured non-OpenAI providers (e.g. kimi), do not advertise
512
+ // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no
513
+ // providers are configured yet (fresh install / catalog bootstrap tests).
514
+ const includeNativeOpenAi = enabledProviders.length === 0 || hasCanonicalOpenai;
515
+ catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi);
501
516
  clampCatalogModelsToCodexSupport(catalog.models);
502
517
 
503
518
  atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
@@ -1,6 +1,6 @@
1
1
  // AUTO-SPLIT facade: original catalog.ts body moved into ./catalog/* modules.
2
2
  // Public surface preserved exactly; importers keep using "src/codex/catalog".
3
- export { isMediaGenerationModelId, readCodexCatalogPath, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
3
+ export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
4
4
  export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
5
5
  export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata";
6
6
  export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
@@ -1,10 +1,18 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, unlinkSync, writeSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
+ import { zstdDecompressSync } from "node:zlib";
4
5
  import { Database } from "bun:sqlite";
5
6
  import { CODEX_HOME } from "./paths";
6
7
  import { atomicWriteFile, getConfigDir } from "../config";
7
8
 
9
+ /**
10
+ * Cap for decompressing a lone `.jsonl.zst` rollout during quarantine restore.
11
+ * Bounds peak memory while reconstructing thread rows; never write the decoded
12
+ * JSONL to disk.
13
+ */
14
+ export const MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
15
+
8
16
  const STATE_DB_PATH = join(CODEX_HOME, "state_5.sqlite");
9
17
  function historyBackupPathFor(stateDbPath: string): string {
10
18
  const normalized = process.platform === "win32" ? resolve(stateDbPath).toLowerCase() : resolve(stateDbPath);
@@ -252,7 +260,7 @@ function parseSessionMetaLine(line: string): ParsedSessionMeta | null {
252
260
  * (codex-rs `apply_session_meta_from_item`). We base our patch on the most recent metadata so we
253
261
  * never resurrect a stale provider that a later app-written `session_meta` already changed.
254
262
  */
255
- function readLatestSessionMeta(path: string): ParsedSessionMeta | null {
263
+ export function readLatestSessionMeta(path: string): ParsedSessionMeta | null {
256
264
  const raw = readFileSync(path, "utf8");
257
265
  const lines = raw.split("\n");
258
266
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -265,6 +273,142 @@ function readLatestSessionMeta(path: string): ParsedSessionMeta | null {
265
273
  return null;
266
274
  }
267
275
 
276
+ /**
277
+ * Fields needed to re-insert a production-shaped `threads` row from a rollout JSONL when a
278
+ * Phase-2 quarantine predates full `satellite-backup.json` thread snapshots.
279
+ *
280
+ * Uses the same last-writer-wins `session_meta` fold as {@link readLatestSessionMeta}, plus the
281
+ * first user-message preview (codex-rs `list.rs` / `EventMsg::UserMessage` path).
282
+ */
283
+ export interface RolloutThreadFields {
284
+ id: string;
285
+ modelProvider: string;
286
+ source: string;
287
+ firstUserMessage: string;
288
+ hasUserEvent: number;
289
+ cwd?: string;
290
+ historyMode?: string;
291
+ cliVersion?: string;
292
+ }
293
+
294
+ function textFromContentParts(content: unknown): string | null {
295
+ if (typeof content === "string" && content.trim()) return content.trim();
296
+ if (!Array.isArray(content)) return null;
297
+ const parts: string[] = [];
298
+ for (const part of content) {
299
+ if (!part || typeof part !== "object") continue;
300
+ const p = part as Record<string, unknown>;
301
+ if (typeof p.text === "string" && p.text.trim()) parts.push(p.text.trim());
302
+ else if (typeof p.input_text === "string" && p.input_text.trim()) parts.push(p.input_text.trim());
303
+ }
304
+ const joined = parts.join("\n").trim();
305
+ return joined || null;
306
+ }
307
+
308
+ /** Extract the first user-message preview from a rollout line, or null. */
309
+ function extractUserMessagePreview(line: string): string | null {
310
+ let parsed: unknown;
311
+ try {
312
+ parsed = JSON.parse(line);
313
+ } catch {
314
+ return null;
315
+ }
316
+ if (!parsed || typeof parsed !== "object") return null;
317
+ const record = parsed as { type?: unknown; payload?: unknown };
318
+ const payload = record.payload;
319
+ if (!payload || typeof payload !== "object") return null;
320
+ const p = payload as Record<string, unknown>;
321
+
322
+ if (record.type === "event_msg") {
323
+ // codex-rs EventMsg::UserMessage — payload.type is "user_message" (or omitted in fixtures).
324
+ if (p.type === "user_message" || typeof p.message === "string") {
325
+ if (typeof p.message === "string" && p.message.trim()) return p.message.trim();
326
+ const fromContent = textFromContentParts(p.content);
327
+ if (fromContent) return fromContent;
328
+ }
329
+ return null;
330
+ }
331
+
332
+ if (record.type === "response_item") {
333
+ if (p.type === "message" && p.role === "user") {
334
+ return textFromContentParts(p.content);
335
+ }
336
+ }
337
+ return null;
338
+ }
339
+
340
+ /**
341
+ * Reconstruct thread identity + listing fields from a staged/restored rollout JSONL.
342
+ * Returns null when the file is missing or has no parseable `session_meta`.
343
+ *
344
+ * Accepts plain `.jsonl` or a lone `.jsonl.zst` (legacy Phase-2 quarantine). Compressed
345
+ * rollouts are decompressed in memory with {@link MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES};
346
+ * no decompressed copy is written to disk.
347
+ */
348
+ export function readThreadFieldsFromRollout(path: string): RolloutThreadFields | null {
349
+ if (!path || !existsSync(path)) return null;
350
+ let raw: string;
351
+ try {
352
+ raw = path.endsWith(".zst")
353
+ ? decompressRolloutZstUtf8(path)
354
+ : readFileSync(path, "utf8");
355
+ } catch {
356
+ return null;
357
+ }
358
+ return parseThreadFieldsFromRolloutText(raw);
359
+ }
360
+
361
+ function decompressRolloutZstUtf8(
362
+ path: string,
363
+ maxBytes: number = MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES,
364
+ ): string {
365
+ const compressed = readFileSync(path);
366
+ const decoded = zstdDecompressSync(compressed as Uint8Array<ArrayBuffer>, {
367
+ maxOutputLength: maxBytes,
368
+ });
369
+ if (decoded.byteLength > maxBytes) {
370
+ throw new Error("rollout_zst_too_large");
371
+ }
372
+ return new TextDecoder().decode(decoded);
373
+ }
374
+
375
+ function parseThreadFieldsFromRolloutText(raw: string): RolloutThreadFields | null {
376
+ const lines = raw.split("\n");
377
+ let latest: ParsedSessionMeta | null = null;
378
+ let firstUserMessage = "";
379
+ for (const line of lines) {
380
+ if (!line) continue;
381
+ if (line.includes("\"session_meta\"")) {
382
+ const meta = parseSessionMetaLine(line);
383
+ if (meta) latest = meta;
384
+ }
385
+ if (!firstUserMessage) {
386
+ const preview = extractUserMessagePreview(line);
387
+ if (preview) firstUserMessage = preview;
388
+ }
389
+ }
390
+ if (!latest) return null;
391
+ const payload = latest.record.payload;
392
+ const id = typeof payload.id === "string" ? payload.id : "";
393
+ if (!id) return null;
394
+ const modelProvider = typeof payload.model_provider === "string" && payload.model_provider
395
+ ? payload.model_provider
396
+ : "openai";
397
+ const source = typeof payload.source === "string" && payload.source
398
+ ? payload.source
399
+ : "cli";
400
+ return {
401
+ id,
402
+ modelProvider,
403
+ source,
404
+ firstUserMessage,
405
+ hasUserEvent: firstUserMessage.trim() ? 1 : 0,
406
+ ...(typeof payload.cwd === "string" ? { cwd: payload.cwd } : {}),
407
+ ...(typeof payload.history_mode === "string" ? { historyMode: payload.history_mode } : {}),
408
+ ...(typeof payload.cli_version === "string" ? { cliVersion: payload.cli_version } : {}),
409
+ };
410
+ }
411
+
268
412
  /**
269
413
  * Make a thread's rollout reflect a provider/source change by APPENDING a new `session_meta` line,
270
414
  * rather than rewriting line 1. The appended line clones the latest metadata payload (so no field