@bitkyc08/opencodex 2.42.0 → 2.43.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 (246) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -1,3 +1,4 @@
1
+ import { parseResetCooldownMs } from "../codex/routing";
1
2
  import { classifyError, isCyberPolicyCode } from "../lib/errors";
2
3
  import type { OcxComboTarget } from "../types";
3
4
  import { targetKey } from "./types";
@@ -197,6 +198,7 @@ export function coolComboTarget(
197
198
  target: Pick<OcxComboTarget, "provider" | "model">,
198
199
  options?: {
199
200
  retryAfter?: string | null;
201
+ resetAt?: unknown | unknown[];
200
202
  now?: number;
201
203
  cooldownMs?: number;
202
204
  writerGeneration?: number;
@@ -209,8 +211,12 @@ export function coolComboTarget(
209
211
  const writerGeneration = options?.writerGeneration ?? captureConfigGeneration();
210
212
  const ownerKey = `${comboId}::${targetKey(target)}`;
211
213
  if (writerGeneration < lastReconciledGeneration && !liveComboTargets.has(ownerKey)) return;
212
- const cooldownMs = options?.cooldownMs
213
- ?? parseRetryAfterMs(options?.retryAfter, now)
214
+ // A server-provided Retry-After is authoritative, including an immediate `0` directive.
215
+ // A quota reset is the next-most-specific signal (#3256); configured and default cooldowns
216
+ // are only fallbacks when upstream supplied neither usable value.
217
+ const cooldownMs = parseRetryAfterMs(options?.retryAfter, now, { preserveImmediate: true })
218
+ ?? parseResetCooldownMs(options?.resetAt, now)
219
+ ?? options?.cooldownMs
214
220
  ?? (isTransientRequestRateLimit({
215
221
  status: options?.status,
216
222
  code: options?.code,
@@ -222,6 +228,32 @@ export function coolComboTarget(
222
228
  sweepExpiredOnWrite(now);
223
229
  }
224
230
 
231
+ export function earliestComboCooldown(
232
+ comboId: string,
233
+ targets: Iterable<Pick<OcxComboTarget, "provider" | "model">>,
234
+ now = Date.now(),
235
+ ): { expiry: number; target: Pick<OcxComboTarget, "provider" | "model"> } | undefined {
236
+ let earliest: { expiry: number; target: Pick<OcxComboTarget, "provider" | "model"> } | undefined;
237
+ for (const target of targets) {
238
+ const key = cooldownMapKey(comboId, target);
239
+ const entry = targetCooldowns.get(key);
240
+ if (!entry || entry.cooldownUntil <= now) continue;
241
+ if (earliest === undefined || entry.cooldownUntil < earliest.expiry) {
242
+ earliest = { expiry: entry.cooldownUntil, target };
243
+ }
244
+ }
245
+ return earliest;
246
+ }
247
+
248
+ /** Public convenience wrapper returning only the earliest cooldown expiry. */
249
+ export function earliestComboCooldownExpiry(
250
+ comboId: string,
251
+ targets: Iterable<Pick<OcxComboTarget, "provider" | "model">>,
252
+ now = Date.now(),
253
+ ): number | undefined {
254
+ return earliestComboCooldown(comboId, targets, now)?.expiry;
255
+ }
256
+
225
257
  export function reconcileComboTargetCooldowns(context: GenerationContext): number {
226
258
  if (context.generation <= lastReconciledGeneration) return 0;
227
259
  liveComboTargets = new Set(context.comboTargets);
@@ -253,7 +285,7 @@ export function clearComboTargetCooldowns(comboId?: string): void {
253
285
  }
254
286
 
255
287
  export type ComboFailureDecision = "hop" | "stop";
256
- export type ComboFailureCooldownScope = "target" | "provider";
288
+ export type ComboFailureCooldownScope = "none" | "target" | "provider";
257
289
 
258
290
  function normalizedFailureCode(code?: string | null): string {
259
291
  return code?.trim().toLowerCase().replaceAll("-", "_") ?? "";
@@ -272,17 +304,71 @@ function isProviderScopedQuotaCap(
272
304
  ) {
273
305
  return true;
274
306
  }
275
- return normalizedCode === "free_rate_limited"
276
- || text.includes("err_free_prompt_cap")
307
+ return text.includes("err_free_prompt_cap")
277
308
  || (text.includes("free tier") && text.includes("single request"));
278
309
  }
279
310
 
311
+ /**
312
+ * A free-tier cap the upstream evaluates PER REQUEST rather than per account window. These
313
+ * needles used to reach only `isProviderScopedQuotaCap`, so a single oversized free-tier prompt
314
+ * cooled the whole provider for every other combo — including the shorter requests that same
315
+ * provider would still have served. `free_rate_limited` also left the provider-scoped predicate
316
+ * for the same reason; it stays a hop signal, but stops recording provider-wide evidence.
317
+ */
318
+ function isRequestLocalFreePromptCap(
319
+ status: number | undefined,
320
+ message: string,
321
+ code?: string | null,
322
+ ): boolean {
323
+ if (status !== 400) return false;
324
+ const text = message.toLowerCase();
325
+ if (normalizedFailureCode(code) === "free_rate_limited") return true;
326
+ if (text.includes("err_free_prompt_cap")) return true;
327
+ return text.includes("free tier") && (text.includes("single request") || text.includes("prompt"));
328
+ }
329
+
330
+ /**
331
+ * Failures that describe the SHAPE of this request rather than the health of the target.
332
+ * Cooling anything for these is wrong twice over: the target is fine, and the next request
333
+ * (shorter prompt, smaller tool catalog) would have succeeded against it.
334
+ */
335
+ const REQUEST_SHAPE_FAILURE_CODES = new Set([
336
+ "input_admission_refused",
337
+ "context_length_exceeded",
338
+ "tool_catalog_too_large",
339
+ "cursor_root_envelope_limit",
340
+ "target_incompatible",
341
+ ]);
342
+
343
+ /** Credential/billing failures that every target sharing the provider inherits. */
344
+ const PROVIDER_SCOPED_FAILURE_CODES = new Set([
345
+ "invalid_api_key",
346
+ "insufficient_quota",
347
+ "subscription_required",
348
+ "payment_required",
349
+ "billing_error",
350
+ "insufficient_balance",
351
+ ]);
352
+
280
353
  export function comboFailureCooldownScope(
281
354
  status: number,
282
355
  message: string,
283
356
  options?: { code?: string | null },
284
357
  ): ComboFailureCooldownScope {
285
- return isProviderScopedQuotaCap(status, message, options?.code) ? "provider" : "target";
358
+ const code = normalizedFailureCode(options?.code);
359
+ // Request-shape refusals first: an oversized request must not cool a healthy target.
360
+ if (
361
+ status === 413
362
+ || REQUEST_SHAPE_FAILURE_CODES.has(code)
363
+ || isRequestLocalFreePromptCap(status, message, options?.code)
364
+ || isProviderTargetContextOverflow(status, message, options?.code)
365
+ ) return "none";
366
+ if (isProviderScopedQuotaCap(status, message, options?.code)) return "provider";
367
+ // A rejected or unpaid credential is provider-wide evidence: every target that routes
368
+ // through the same provider row carries the same key and will fail identically.
369
+ if (status === 401 || status === 402 || status === 403) return "provider";
370
+ if (PROVIDER_SCOPED_FAILURE_CODES.has(code)) return "provider";
371
+ return "target";
286
372
  }
287
373
 
288
374
  function isModelLifecycleGone(
@@ -307,6 +393,19 @@ function isModelLifecycleGone(
307
393
  );
308
394
  }
309
395
 
396
+ function isProviderTargetContextOverflow(
397
+ status: number,
398
+ message: string,
399
+ code?: string | null,
400
+ ): boolean {
401
+ if (status !== 400) return false;
402
+ const normalizedCode = normalizedFailureCode(code);
403
+ const text = message.toLowerCase();
404
+ if (text.includes("invalid_request_prompt_too_long")) return true;
405
+ return normalizedCode === "5059"
406
+ && /\bprompt\s+\d+\s*>\s*\d+\s+maximum context length\b/i.test(message);
407
+ }
408
+
310
409
  export function comboFailureDecision(
311
410
  status: number,
312
411
  message: string,
@@ -324,6 +423,10 @@ export function comboFailureDecision(
324
423
  if (isModelLifecycleGone(status, message, options?.code)) return "hop";
325
424
  const error = classifyError(status, "upstream_error", message);
326
425
  if (isCyberPolicyCode(error.code)) return "stop";
426
+ // A provider can expose its own target hard cap with a non-semantic vendor code
427
+ // (for example 5059 + invalid_request_prompt_too_long). That is evidence that this
428
+ // target is too small, not that every later combo target is incapable of serving it.
429
+ if (isProviderTargetContextOverflow(status, message, options?.code)) return "hop";
327
430
  // A local input-admission refusal (#1524) says "this candidate cannot fit the request",
328
431
  // not "the request is impossible": the next candidate may have a larger context window.
329
432
  //
@@ -347,15 +450,30 @@ export function comboFailureDecision(
347
450
  if (isProviderScopedQuotaCap(status, message, options?.code || error.code)) {
348
451
  return "hop";
349
452
  }
453
+ // A model-scoped rejection is target-local: this provider does not serve THIS model, which
454
+ // says nothing about the next combo target. Structured code only, plus the explicit prose
455
+ // form upstreams emit when they carry no code, so an unrelated 400 stays terminal.
456
+ const failureCode = normalizedFailureCode(options?.code || error.code);
457
+ if (["model_not_found", "model_unavailable", "unsupported_model"].includes(failureCode)) {
458
+ return "hop";
459
+ }
460
+ // `free_rate_limited` no longer routes through `isProviderScopedQuotaCap` (it is a
461
+ // per-request cap, not provider-wide evidence), so keep its hop verdict explicit here.
462
+ if (failureCode === "free_rate_limited") return "hop";
350
463
  if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
351
464
  return "stop";
352
465
  }
353
- if ([401, 403, 404, 408, 429].includes(status) || status >= 500) return "hop";
466
+ // 402 (payment required) and 425 (too early) are provider-state signals, not verdicts about
467
+ // the request: another combo target can still serve it.
468
+ if ([401, 402, 403, 404, 408, 425, 429].includes(status) || status >= 500) return "hop";
354
469
  if ([
355
470
  "permission_denied",
356
471
  "subscription_required",
357
472
  "invalid_api_key",
358
473
  "insufficient_quota",
474
+ "payment_required",
475
+ "billing_error",
476
+ "insufficient_balance",
359
477
  "rate_limit_exceeded",
360
478
  "server_is_overloaded",
361
479
  "upstream_server_error",
@@ -0,0 +1,89 @@
1
+ import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models";
2
+ import type { OcxComboConfig, OcxComboTarget, OcxConfig } from "../types";
3
+
4
+ export const COMBO_NAMESPACE = "combo";
5
+
6
+ export function preservesPhysicalComboProvider(
7
+ config: Pick<OcxConfig, "providers" | "combos">,
8
+ ): boolean {
9
+ return Object.hasOwn(config.providers, COMBO_NAMESPACE)
10
+ && Object.keys(config.combos ?? {}).length === 0;
11
+ }
12
+
13
+ const COMBO_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
14
+
15
+ /** True only for an explicitly opted-in bare native-family alias. */
16
+ export function isNativeAliasCombo(
17
+ combo: { alias?: string | null; nativeAlias?: boolean },
18
+ ): boolean {
19
+ const alias = typeof combo.alias === "string" ? combo.alias.trim() : "";
20
+ return combo.nativeAlias === true
21
+ && SUPPORTED_NATIVE_OPENAI_SLUGS.has(alias);
22
+ }
23
+
24
+ export function targetKey(target: Pick<OcxComboTarget, "provider" | "model">): string {
25
+ return `${target.provider}/${target.model}`;
26
+ }
27
+
28
+ export function parseComboModelId(modelId: string): string | null {
29
+ const slash = modelId.indexOf("/");
30
+ if (slash <= 0 || modelId.slice(0, slash) !== COMBO_NAMESPACE) return null;
31
+ const id = modelId.slice(slash + 1);
32
+ return id.length > 0 ? id : null;
33
+ }
34
+
35
+ export function comboModelId(id: string): string {
36
+ return `${COMBO_NAMESPACE}/${id}`;
37
+ }
38
+
39
+ /** Public model id clients request: the alias when set, else the default `combo/<id>`. */
40
+ export function comboPublicModelId(id: string, combo: { alias?: string | null }): string {
41
+ const alias = typeof combo.alias === "string" ? combo.alias.trim() : "";
42
+ return alias || comboModelId(id);
43
+ }
44
+
45
+ /**
46
+ * Persisted selector that hides a combo from discovery. Native aliases keep the canonical
47
+ * `combo/<id>` selector because their bare public id remains the native OpenAI disable key.
48
+ */
49
+ export function comboDisabledModelId(
50
+ id: string,
51
+ combo: { alias?: string | null; nativeAlias?: boolean },
52
+ ): string {
53
+ return isNativeAliasCombo(combo) ? comboModelId(id) : comboPublicModelId(id, combo);
54
+ }
55
+
56
+ /** Every persisted selector that can refer to this combo in `disabledModels`. */
57
+ export function comboDisabledModelSelectors(
58
+ id: string,
59
+ combo: { alias?: string | null; nativeAlias?: boolean },
60
+ ): string[] {
61
+ const canonical = comboModelId(id);
62
+ const preferred = comboDisabledModelId(id, combo);
63
+ return preferred === canonical ? [canonical] : [canonical, preferred];
64
+ }
65
+
66
+ /**
67
+ * Resolve a client-requested model id to a combo config key. The canonical `combo/<id>`
68
+ * form wins first (back-compat); otherwise an exact alias match across configured combos.
69
+ */
70
+ export function resolveComboId(
71
+ config: { combos?: Record<string, OcxComboConfig> },
72
+ modelId: string,
73
+ ): string | null {
74
+ const direct = parseComboModelId(modelId);
75
+ if (direct) return direct;
76
+ const combos = config.combos;
77
+ if (!combos) return null;
78
+ for (const [id, raw] of Object.entries(combos)) {
79
+ if (!raw || typeof raw !== "object") continue;
80
+ const alias = typeof raw.alias === "string" ? raw.alias.trim() : "";
81
+ if (alias && alias === modelId) return id;
82
+ }
83
+ return null;
84
+ }
85
+
86
+
87
+ export function isValidComboId(id: string): boolean {
88
+ return COMBO_ID_PATTERN.test(id);
89
+ }
@@ -1,4 +1,5 @@
1
1
  export {
2
+ COMBO_DEFAULT_WAIT_FOR_COOLDOWN_MS,
2
3
  COMBO_NAMESPACE,
3
4
  comboAliasIssues,
4
5
  comboConfigError,
@@ -25,6 +26,7 @@ export {
25
26
  noteComboFailure,
26
27
  noteComboSuccess,
27
28
  pickComboTarget,
29
+ pickComboTargetWithWait,
28
30
  tryPickComboModel,
29
31
  UnknownComboError,
30
32
  type ComboPick,
@@ -34,6 +36,8 @@ export {
34
36
  comboCooldownRetryAfterSeconds,
35
37
  COMBO_REQUEST_RATE_COOLDOWN_MS,
36
38
  coolComboTarget,
39
+ earliestComboCooldown,
40
+ earliestComboCooldownExpiry,
37
41
  isComboTargetInCooldown,
38
42
  isTransientRequestRateLimit,
39
43
  parseRetryAfterMs,
@@ -1,7 +1,13 @@
1
1
  import type { OcxComboTarget, OcxConfig } from "../types";
2
2
  import { getCachedProviderQuota } from "../providers/quota-routing-cache";
3
3
  import type { ProviderQuota } from "../providers/quota-types";
4
- import { coolComboTarget, isComboTargetInCooldown, type ComboFailureCooldownScope } from "./failover";
4
+ import { sleepWithAbort } from "../lib/upstream-retry";
5
+ import {
6
+ coolComboTarget,
7
+ earliestComboCooldown,
8
+ isComboTargetInCooldown,
9
+ type ComboFailureCooldownScope,
10
+ } from "./failover";
5
11
  import { quotaResetRemainingMs } from "./reset-window";
6
12
  import { getCombo, resolveComboId, targetKey } from "./types";
7
13
  import type { NormalizedComboConfig } from "./types";
@@ -155,6 +161,7 @@ export function pickComboTarget(
155
161
  const eligible = (target: Required<OcxComboTarget>): boolean =>
156
162
  targetProviderIsUsable(config, target)
157
163
  && !cachedProviderQuotaIsExhausted(getCachedProviderQuota(target.provider, now), now)
164
+ && !isComboTargetInCooldown(comboId, target, now)
158
165
  && !excluded.has(targetKey(target))
159
166
  && (options.eligible?.(target) ?? true);
160
167
 
@@ -274,7 +281,9 @@ export function advanceComboAfterFailure(
274
281
  pick: ComboPick,
275
282
  options: {
276
283
  retryAfter?: string | null;
284
+ resetAt?: unknown | unknown[];
277
285
  now?: number;
286
+ cooldownMs?: number;
278
287
  eligible?: (target: Required<OcxComboTarget>) => boolean;
279
288
  cooldownScope?: ComboFailureCooldownScope;
280
289
  status?: number;
@@ -284,14 +293,19 @@ export function advanceComboAfterFailure(
284
293
  ): ComboPick | null {
285
294
  noteComboFailure(pick.comboId, pick.target, pick.writerGeneration);
286
295
  const combo = getCombo(config, pick.comboId);
287
- const cooldownTargets = options.cooldownScope === "provider" && combo
288
- ? combo.targets.filter(target => target.provider === pick.target.provider)
289
- : [pick.target];
290
- for (const target of cooldownTargets) {
291
- coolComboTarget(pick.comboId, target, {
292
- ...options,
293
- writerGeneration: pick.writerGeneration,
294
- });
296
+ // "none" records no cooldown at all: the failure described the request, not the target, so
297
+ // the target must stay immediately selectable for the next (differently shaped) request.
298
+ if (options.cooldownScope !== "none") {
299
+ const cooldownTargets = options.cooldownScope === "provider" && combo
300
+ ? combo.targets.filter(target => target.provider === pick.target.provider)
301
+ : [pick.target];
302
+ for (const target of cooldownTargets) {
303
+ coolComboTarget(pick.comboId, target, {
304
+ ...options,
305
+ cooldownMs: options.cooldownMs ?? combo?.cooldownMs,
306
+ writerGeneration: pick.writerGeneration,
307
+ });
308
+ }
295
309
  }
296
310
  return pickComboTarget(config, pick.comboId, {
297
311
  exclude: pick.attempted,
@@ -301,6 +315,63 @@ export function advanceComboAfterFailure(
301
315
  });
302
316
  }
303
317
 
318
+ export async function pickComboTargetWithWait(
319
+ config: OcxConfig,
320
+ comboId: string,
321
+ options: {
322
+ exclude?: Iterable<string>;
323
+ eligible?: (target: Required<OcxComboTarget>) => boolean;
324
+ waitForCooldownMs: number;
325
+ abortSignal?: AbortSignal;
326
+ now?: number;
327
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
328
+ },
329
+ ): Promise<ComboPick | null> {
330
+ const now = options.now ?? Date.now();
331
+ const excluded = new Set(options.exclude ?? []);
332
+ const customEligible = options.eligible;
333
+ const eligible = (target: Required<OcxComboTarget>): boolean =>
334
+ !isComboTargetInCooldown(comboId, target, now)
335
+ && (customEligible?.(target) ?? true);
336
+ const pick = pickComboTarget(config, comboId, { exclude: excluded, eligible, now });
337
+ if (pick || options.waitForCooldownMs <= 0 || options.abortSignal?.aborted) return pick;
338
+ const combo = getCombo(config, comboId);
339
+ if (!combo) throw new UnknownComboError(comboId);
340
+ const waitingTargets = combo.targets.filter(target =>
341
+ targetProviderIsUsable(config, target)
342
+ && !cachedProviderQuotaIsExhausted(getCachedProviderQuota(target.provider, now), now)
343
+ && !excluded.has(targetKey(target))
344
+ && isComboTargetInCooldown(comboId, target, now)
345
+ && (customEligible?.(target) ?? true),
346
+ );
347
+ const earliest = earliestComboCooldown(comboId, waitingTargets, now);
348
+ if (earliest === undefined) return null;
349
+ const delay = earliest.expiry - now;
350
+ if (delay > options.waitForCooldownMs) return null;
351
+ // The expiry computation above is the single source of truth for the wait budget.
352
+ // Its target preserves configured order for ties.
353
+ const target = earliest.target;
354
+ console.warn(
355
+ `[combo] ${comboId}: all targets cooling, waiting ${delay}ms for ${targetKey(target)}`,
356
+ );
357
+ try {
358
+ await (options.sleep ?? sleepWithAbort)(delay, options.abortSignal);
359
+ } catch (error) {
360
+ if (options.abortSignal?.aborted) return null;
361
+ throw error;
362
+ }
363
+ if (options.abortSignal?.aborted) return null;
364
+ // Management updates can delete or rename the combo while this request sleeps.
365
+ if (!getCombo(config, comboId)) return null;
366
+ return pickComboTarget(config, comboId, {
367
+ exclude: excluded,
368
+ now: now + delay,
369
+ eligible: targetCandidate =>
370
+ !isComboTargetInCooldown(comboId, targetCandidate, now + delay)
371
+ && (customEligible?.(targetCandidate) ?? true),
372
+ });
373
+ }
374
+
304
375
  export function reconcileComboRotationState(context: GenerationContext): number {
305
376
  if (context.generation <= lastReconciledGeneration) return 0;
306
377
  let removed = 0;
@@ -1,25 +1,11 @@
1
1
  import { isCodexReasoningEffort } from "../reasoning-effort";
2
2
  import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models";
3
- import type {
4
- OcxComboConfig,
5
- OcxComboDefaultEffort,
6
- OcxComboReasoningEffortMode,
7
- OcxComboStrategy,
8
- OcxComboTarget,
9
- OcxConfig,
10
- OcxProviderConfig,
11
- } from "../types";
3
+ import type { OcxComboConfig, OcxComboDefaultEffort, OcxComboReasoningEffortMode, OcxComboStrategy, OcxComboTarget, OcxProviderConfig } from "../types";
4
+ import { COMBO_NAMESPACE, isValidComboId, targetKey } from "./identifiers";
12
5
 
13
- export const COMBO_NAMESPACE = "combo";
6
+ export const COMBO_DEFAULT_WAIT_FOR_COOLDOWN_MS = 0;
7
+ export { COMBO_NAMESPACE, preservesPhysicalComboProvider, isNativeAliasCombo, targetKey, parseComboModelId, comboModelId, comboPublicModelId, comboDisabledModelId, comboDisabledModelSelectors, resolveComboId, isValidComboId } from "./identifiers";
14
8
 
15
- export function preservesPhysicalComboProvider(
16
- config: Pick<OcxConfig, "providers" | "combos">,
17
- ): boolean {
18
- return Object.hasOwn(config.providers, COMBO_NAMESPACE)
19
- && Object.keys(config.combos ?? {}).length === 0;
20
- }
21
-
22
- const COMBO_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
23
9
  /**
24
10
  * Public alias shape: one optional "/" segment, each segment id-shaped. Bare aliases
25
11
  * (no "/") are the masquerade case — the combo answers to a mandated model id with no
@@ -37,6 +23,8 @@ export interface ComboValidationIssue {
37
23
  export interface NormalizedComboConfig {
38
24
  strategy: OcxComboStrategy;
39
25
  stickyLimit: number;
26
+ cooldownMs?: number;
27
+ waitForCooldownMs: number;
40
28
  defaultEffort: OcxComboDefaultEffort | null;
41
29
  /** Picker-ladder derivation policy; `strict` preserves the legacy intersection rule. */
42
30
  reasoningEffortMode: OcxComboReasoningEffortMode;
@@ -51,77 +39,6 @@ export interface NormalizedComboConfig {
51
39
  targets: Array<Required<OcxComboTarget>>;
52
40
  }
53
41
 
54
- /** True only for an explicitly opted-in bare native-family alias. */
55
- export function isNativeAliasCombo(
56
- combo: { alias?: string | null; nativeAlias?: boolean },
57
- ): boolean {
58
- const alias = typeof combo.alias === "string" ? combo.alias.trim() : "";
59
- return combo.nativeAlias === true
60
- && SUPPORTED_NATIVE_OPENAI_SLUGS.has(alias);
61
- }
62
-
63
- export function targetKey(target: Pick<OcxComboTarget, "provider" | "model">): string {
64
- return `${target.provider}/${target.model}`;
65
- }
66
-
67
- export function parseComboModelId(modelId: string): string | null {
68
- const slash = modelId.indexOf("/");
69
- if (slash <= 0 || modelId.slice(0, slash) !== COMBO_NAMESPACE) return null;
70
- const id = modelId.slice(slash + 1);
71
- return id.length > 0 ? id : null;
72
- }
73
-
74
- export function comboModelId(id: string): string {
75
- return `${COMBO_NAMESPACE}/${id}`;
76
- }
77
-
78
- /** Public model id clients request: the alias when set, else the default `combo/<id>`. */
79
- export function comboPublicModelId(id: string, combo: { alias?: string | null }): string {
80
- const alias = typeof combo.alias === "string" ? combo.alias.trim() : "";
81
- return alias || comboModelId(id);
82
- }
83
-
84
- /**
85
- * Persisted selector that hides a combo from discovery. Native aliases keep the canonical
86
- * `combo/<id>` selector because their bare public id remains the native OpenAI disable key.
87
- */
88
- export function comboDisabledModelId(
89
- id: string,
90
- combo: { alias?: string | null; nativeAlias?: boolean },
91
- ): string {
92
- return isNativeAliasCombo(combo) ? comboModelId(id) : comboPublicModelId(id, combo);
93
- }
94
-
95
- /** Every persisted selector that can refer to this combo in `disabledModels`. */
96
- export function comboDisabledModelSelectors(
97
- id: string,
98
- combo: { alias?: string | null; nativeAlias?: boolean },
99
- ): string[] {
100
- const canonical = comboModelId(id);
101
- const preferred = comboDisabledModelId(id, combo);
102
- return preferred === canonical ? [canonical] : [canonical, preferred];
103
- }
104
-
105
- /**
106
- * Resolve a client-requested model id to a combo config key. The canonical `combo/<id>`
107
- * form wins first (back-compat); otherwise an exact alias match across configured combos.
108
- */
109
- export function resolveComboId(
110
- config: { combos?: Record<string, OcxComboConfig> },
111
- modelId: string,
112
- ): string | null {
113
- const direct = parseComboModelId(modelId);
114
- if (direct) return direct;
115
- const combos = config.combos;
116
- if (!combos) return null;
117
- for (const [id, raw] of Object.entries(combos)) {
118
- if (!raw || typeof raw !== "object") continue;
119
- const alias = typeof raw.alias === "string" ? raw.alias.trim() : "";
120
- if (alias && alias === modelId) return id;
121
- }
122
- return null;
123
- }
124
-
125
42
  /**
126
43
  * Cross-combo alias checks that need the full combos map (uniqueness). Kept separate
127
44
  * from `comboConfigIssues` so config-file validation and the management API share it.
@@ -230,6 +147,18 @@ export function comboConfigIssues(
230
147
  || body.stickyLimit > 100)) {
231
148
  issues.push({ path: ["stickyLimit"], message: "stickyLimit must be an integer from 1 to 100" });
232
149
  }
150
+ if (body.cooldownMs !== undefined
151
+ && (typeof body.cooldownMs !== "number" || !Number.isInteger(body.cooldownMs)
152
+ || body.cooldownMs < 1
153
+ || body.cooldownMs > 600_000)) {
154
+ issues.push({ path: ["cooldownMs"], message: "cooldownMs must be an integer from 1 to 600000" });
155
+ }
156
+ if (body.waitForCooldownMs !== undefined
157
+ && (typeof body.waitForCooldownMs !== "number" || !Number.isInteger(body.waitForCooldownMs)
158
+ || body.waitForCooldownMs < 0
159
+ || body.waitForCooldownMs > 600_000)) {
160
+ issues.push({ path: ["waitForCooldownMs"], message: "waitForCooldownMs must be an integer from 0 to 600000" });
161
+ }
233
162
  if (body.defaultEffort !== undefined
234
163
  && body.defaultEffort !== null
235
164
  && (typeof body.defaultEffort !== "string" || !isCodexReasoningEffort(body.defaultEffort))) {
@@ -367,6 +296,8 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig
367
296
  return {
368
297
  strategy: raw.strategy ?? "failover",
369
298
  stickyLimit: raw.stickyLimit ?? 1,
299
+ cooldownMs: raw.cooldownMs,
300
+ waitForCooldownMs: raw.waitForCooldownMs ?? COMBO_DEFAULT_WAIT_FOR_COOLDOWN_MS,
370
301
  defaultEffort: raw.defaultEffort ?? null,
371
302
  reasoningEffortMode: raw.reasoningEffortMode === "adaptive" ? "adaptive" : "strict",
372
303
  imageInput: raw.imageInput === "disabled" ? "disabled" : "auto",
@@ -393,10 +324,6 @@ export function comboDefaultEffort(
393
324
  : null;
394
325
  }
395
326
 
396
- export function isValidComboId(id: string): boolean {
397
- return COMBO_ID_PATTERN.test(id);
398
- }
399
-
400
327
  export function listComboIds(config: { combos?: Record<string, OcxComboConfig> }): string[] {
401
328
  return Object.keys(config.combos ?? {}).sort((a, b) => a.localeCompare(b));
402
329
  }
@@ -0,0 +1,24 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { NATIVE_GPT6_ASTRA_MODEL } from "../codex/catalog/native-models";
3
+
4
+ export const SUBAGENT_MODELS_VERSION = 1;
5
+
6
+ /** Native featured defaults; Codex advertises at most five picker-visible rows. */
7
+ export const DEFAULT_SUBAGENT_MODELS = [
8
+ NATIVE_GPT6_ASTRA_MODEL, "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5",
9
+ ];
10
+
11
+ /** One-time upgrade; later user edits (including removing Astra) remain authoritative. */
12
+ export function migrateSubagentModels(config: OcxConfig): boolean {
13
+ if ((config.subagentModelsVersion ?? 0) >= SUBAGENT_MODELS_VERSION) return false;
14
+ if (config.subagentModels === undefined) {
15
+ config.subagentModels = [...DEFAULT_SUBAGENT_MODELS];
16
+ } else {
17
+ const retained = [...new Set([NATIVE_GPT6_ASTRA_MODEL, ...config.subagentModels])].slice(0, 5);
18
+ // Cap first: do not rescue a fifth old choice. Retained 5.5 belongs at the bottom.
19
+ config.subagentModels = retained.filter(model => model !== "gpt-5.5");
20
+ if (retained.includes("gpt-5.5")) config.subagentModels.push("gpt-5.5");
21
+ }
22
+ config.subagentModelsVersion = SUBAGENT_MODELS_VERSION;
23
+ return true;
24
+ }