mixdog 1.2.1 → 1.2.2

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 (233) hide show
  1. package/package.json +1 -1
  2. package/scripts/gate-local.mjs +19 -3
  3. package/scripts/model-catalog-audit.mjs +1 -15
  4. package/src/agents/reviewer/AGENT.md +3 -3
  5. package/src/agents/reviewer/agent.json +1 -1
  6. package/src/defaults/shipped-history.json +1 -0
  7. package/src/repl/slash-commands.mjs +16 -1
  8. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +29 -4
  9. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +10 -0
  10. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +36 -7
  11. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +49 -10
  12. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +40 -3
  13. package/src/runtime/agent/orchestrator/mcp/client.mjs +230 -96
  14. package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +103 -0
  15. package/src/runtime/agent/orchestrator/mcp/oauth-credentials.mjs +150 -0
  16. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +111 -0
  17. package/src/runtime/agent/orchestrator/mcp/oauth-probe.mjs +87 -0
  18. package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +95 -0
  19. package/src/runtime/agent/orchestrator/mcp/oauth-request.mjs +122 -0
  20. package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +9 -0
  21. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +229 -0
  22. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +188 -29
  23. package/src/runtime/agent/orchestrator/providers/anthropic-api-recovery.mjs +2 -1
  24. package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +94 -31
  25. package/src/runtime/agent/orchestrator/providers/anthropic-error-contract.mjs +96 -0
  26. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +39 -18
  27. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens-recovery.mjs +55 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +25 -21
  29. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +3 -1
  30. package/src/runtime/agent/orchestrator/providers/anthropic-midstream-recovery.mjs +18 -4
  31. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +13 -1
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-midstream.mjs +10 -1
  34. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-recovery.mjs +23 -9
  35. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +46 -20
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +14 -9
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +73 -35
  38. package/src/runtime/agent/orchestrator/providers/anthropic-overload-fallback.mjs +69 -0
  39. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +4 -0
  40. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -1
  41. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -7
  42. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +30 -9
  43. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  44. package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +30 -14
  45. package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +2 -1
  46. package/src/runtime/agent/orchestrator/providers/antigravity-retry-policy.mjs +93 -0
  47. package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +82 -9
  48. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +3 -0
  49. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +38 -5
  50. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  51. package/src/runtime/agent/orchestrator/providers/cursor-wire-guards.mjs +97 -2
  52. package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-bridge.mjs +102 -23
  53. package/src/runtime/agent/orchestrator/providers/cursor-wire-transport.mjs +103 -33
  54. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +29 -6
  55. package/src/runtime/agent/orchestrator/providers/cursor.mjs +18 -1
  56. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +77 -17
  57. package/src/runtime/agent/orchestrator/providers/gemini-cache-policy.mjs +1 -1
  58. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +29 -8
  59. package/src/runtime/agent/orchestrator/providers/gemini-error-contract.mjs +191 -0
  60. package/src/runtime/agent/orchestrator/providers/gemini-family.mjs +9 -0
  61. package/src/runtime/agent/orchestrator/providers/gemini-history-signatures.mjs +9 -4
  62. package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +3 -1
  63. package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +180 -0
  64. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +21 -82
  65. package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +4 -1
  66. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -12
  67. package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +21 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +56 -23
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
  70. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +68 -19
  71. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +3 -0
  72. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +2 -1
  73. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +4 -9
  74. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +4 -2
  75. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-result.mjs +22 -0
  76. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +1 -0
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-error-contract.mjs +50 -0
  78. package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +23 -0
  79. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +16 -8
  80. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-state.mjs +3 -2
  81. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +94 -13
  82. package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +193 -0
  83. package/src/runtime/agent/orchestrator/providers/openai-compat-stream-common.mjs +66 -2
  84. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +2 -1
  85. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +6 -0
  86. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +4 -2
  87. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -14
  88. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +17 -3
  89. package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +42 -25
  90. package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +274 -0
  91. package/src/runtime/agent/orchestrator/providers/openai-http-sse-request.mjs +72 -32
  92. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/terminal-frames.mjs +18 -11
  93. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-outcome.mjs +1 -2
  94. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-stamps.mjs +2 -2
  95. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-state.mjs +2 -3
  96. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state.mjs +2 -2
  97. package/src/runtime/agent/orchestrator/providers/openai-http-sse-watchdogs.mjs +1 -3
  98. package/src/runtime/agent/orchestrator/providers/openai-oauth-dispatch.mjs +34 -3
  99. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +5 -1
  100. package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +22 -2
  101. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +5 -0
  102. package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +5 -9
  103. package/src/runtime/agent/orchestrator/providers/openai-retry-policy.mjs +9 -0
  104. package/src/runtime/agent/orchestrator/providers/openai-ws-handshake.mjs +16 -8
  105. package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/attempt-context.mjs +1 -2
  106. package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/policy.mjs +25 -14
  107. package/src/runtime/agent/orchestrator/providers/openai-ws-terminal.mjs +18 -11
  108. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +31 -20
  109. package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +581 -0
  110. package/src/runtime/agent/orchestrator/providers/retry-backoff.mjs +46 -7
  111. package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +118 -219
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +2 -0
  113. package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +148 -45
  114. package/src/runtime/agent/orchestrator/providers/retry-midstream.mjs +81 -16
  115. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +3 -0
  116. package/src/runtime/agent/orchestrator/runtime-core/goal-reminder.mjs +0 -18
  117. package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +57 -28
  118. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +19 -16
  119. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +1 -97
  120. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +3 -1
  121. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +4 -0
  122. package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +102 -18
  123. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +3 -1
  124. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +30 -2
  125. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +11 -2
  126. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +41 -21
  127. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +5 -10
  128. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +18 -7
  130. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +155 -29
  131. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +6 -3
  132. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +16 -0
  133. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +22 -10
  134. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +5 -0
  136. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +3 -0
  137. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +20 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +2 -2
  139. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/deadline-plan.mjs +12 -15
  140. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  141. package/src/runtime/memory/lib/pg/process.mjs +34 -24
  142. package/src/runtime/memory/lib/pg/supervisor.mjs +16 -13
  143. package/src/runtime/office/README.md +1 -1
  144. package/src/runtime/office/shared/values.mjs +0 -5
  145. package/src/runtime/shared/background-tasks.mjs +92 -14
  146. package/src/runtime/shared/err-text.mjs +25 -0
  147. package/src/runtime/shared/error-presentation.mjs +159 -19
  148. package/src/runtime/shared/llm/anthropic-betas.mjs +4 -3
  149. package/src/runtime/shared/llm/anthropic-effort.mjs +123 -134
  150. package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +50 -14
  151. package/src/runtime/shared/llm/model-catalog.mjs +53 -40
  152. package/src/runtime/shared/llm/openai-daybreak.mjs +4 -16
  153. package/src/runtime/shared/llm/provider-catalog-registry.mjs +11 -0
  154. package/src/runtime/shared/llm/provider-model-identities.mjs +13 -4
  155. package/src/runtime/shared/llm/usage-rollup.mjs +0 -1
  156. package/src/runtime/shared/model-display.mjs +13 -0
  157. package/src/runtime/shared/quota-resume-store.mjs +11 -0
  158. package/src/runtime/shared/session-baseline-store.mjs +960 -0
  159. package/src/runtime/shared/statusline/statusline-format.mjs +4 -4
  160. package/src/runtime/shared/task-notification-envelope.mjs +2 -2
  161. package/src/runtime/shared/turn-changes.mjs +447 -219
  162. package/src/runtime/shared/turn-snapshot-store.mjs +182 -1
  163. package/src/runtime/shared/turn-snapshot.mjs +170 -48
  164. package/src/runtime/shared/turn-worktree-snapshot.mjs +135 -46
  165. package/src/runtime/shared/worktrees/contract.mjs +0 -24
  166. package/src/runtime/shared/worktrees/test-support.mjs +19 -4
  167. package/src/session-runtime/boot/apis.mjs +6 -0
  168. package/src/session-runtime/boot/infrastructure.mjs +6 -0
  169. package/src/session-runtime/lifecycle/session-catalog.mjs +2 -0
  170. package/src/session-runtime/lifecycle/teardown.mjs +1 -1
  171. package/src/session-runtime/mcp-glue/connect.mjs +29 -9
  172. package/src/session-runtime/mcp-glue/server-catalog.mjs +20 -3
  173. package/src/session-runtime/mcp-glue.mjs +3 -0
  174. package/src/session-runtime/mcp-oauth-login.mjs +174 -0
  175. package/src/session-runtime/model-route/model-switch-target.mjs +34 -0
  176. package/src/session-runtime/model-route/set-route.mjs +83 -15
  177. package/src/session-runtime/model-route/web-search-route.mjs +1 -1
  178. package/src/session-runtime/native-web-search/routes.mjs +22 -3
  179. package/src/session-runtime/provider-models/model-meta.mjs +12 -3
  180. package/src/session-runtime/quick-model-rows/web-search-rows.mjs +13 -8
  181. package/src/session-runtime/quick-web-search-models.mjs +13 -38
  182. package/src/session-runtime/resource-extension-sync.mjs +4 -0
  183. package/src/session-runtime/resource-mcp-api.mjs +98 -2
  184. package/src/session-runtime/runtime-review-api.mjs +13 -0
  185. package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +13 -1
  186. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +7 -2
  187. package/src/session-runtime/services/agent-tool/send-flow.mjs +22 -4
  188. package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +2 -0
  189. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +22 -5
  190. package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -1
  191. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +2 -2
  192. package/src/session-runtime/services/agent-tool/worker-rows.mjs +17 -0
  193. package/src/session-runtime/settings-compaction-api.mjs +1 -0
  194. package/src/standalone/daemon-drain-policy.mjs +8 -0
  195. package/src/standalone/daemon-telemetry.mjs +55 -0
  196. package/src/standalone/daemon.mjs +3 -7
  197. package/src/standalone/session-protocol.mjs +7 -0
  198. package/src/standalone/session-service/agent-tree/agent-cancel.mjs +33 -2
  199. package/src/standalone/session-service/agent-tree/agent-turns.mjs +26 -10
  200. package/src/standalone/session-service/agent-tree.mjs +12 -0
  201. package/src/standalone/session-service/quota-resume.mjs +8 -0
  202. package/src/standalone/session-service/retention/busy.mjs +15 -1
  203. package/src/standalone/session-service-api.mjs +5 -0
  204. package/src/standalone/session-service.mjs +1 -0
  205. package/src/tui/app/app-view.jsx +1 -0
  206. package/src/tui/app/create-app-pickers.mjs +4 -0
  207. package/src/tui/app/extension-pickers/mcp-login.mjs +107 -0
  208. package/src/tui/app/extension-pickers/mcp-servers-picker.mjs +2 -1
  209. package/src/tui/app/extension-pickers.mjs +2 -1
  210. package/src/tui/app/model-options.mjs +11 -16
  211. package/src/tui/app/slash-commands.mjs +7 -2
  212. package/src/tui/app/slash-dispatch/panel-commands.mjs +16 -1
  213. package/src/tui/components/StatusLine.jsx +2 -0
  214. package/src/tui/components/status-line/local-line.mjs +5 -2
  215. package/src/tui/components/status-line/use-statusline-text.mjs +7 -1
  216. package/src/tui/dist/index.mjs +816 -43
  217. package/src/tui/session/quota-resume.mjs +46 -14
  218. package/src/tui/session/session-api/commands.mjs +33 -24
  219. package/src/tui/session/session-api/extensions.mjs +22 -0
  220. package/src/tui/session/session-api/integrations.mjs +7 -0
  221. package/src/tui/session/session-api/lifecycle.mjs +5 -1
  222. package/src/tui/session/session-api/routes.mjs +71 -7
  223. package/src/tui/session/session-api/settings.mjs +5 -14
  224. package/src/tui/session/session-flow/drain.mjs +7 -1
  225. package/src/tui/session/session-flow/failed-turn-rewind.mjs +10 -4
  226. package/src/tui/session/turn.mjs +38 -15
  227. package/src/tui/statusline-ansi-bridge.mjs +1 -1
  228. package/src/ui/statusline-agents.mjs +15 -4
  229. package/src/ui/statusline.mjs +7 -3
  230. package/src/ui/streaming-markdown-heal.mjs +34 -3
  231. package/src/vendor/statusline/src/gateway/claude-current.mjs +14 -4
  232. package/src/runtime/memory/lib/memory-session-merge.mjs +0 -32
  233. package/src/runtime/shared/llm/usage-session-history.mjs +0 -266
@@ -14,7 +14,7 @@ import { appendAgentTrace } from '../agent-trace.mjs';
14
14
  import { resolveProviderCacheKey, resolveProviderPromptCacheLane } from '../agent-runtime/cache-strategy.mjs';
15
15
  import { shouldFallbackTransport } from './retry-classifier.mjs';
16
16
  import { envFlag as _envFlag } from '../../../shared/env.mjs';
17
- import { getModelsDevRowSync } from '../../../shared/llm/model-catalog.mjs';
17
+ import { getModelMetadataSync, getModelsDevRowSync } from '../../../shared/llm/model-catalog.mjs';
18
18
  import {
19
19
  traceHash,
20
20
  stableTraceStringify,
@@ -166,26 +166,35 @@ export function normalizeXaiReasoningEffort(value) {
166
166
  return ['low', 'medium', 'high', 'xhigh'].includes(effort) ? effort : null;
167
167
  }
168
168
 
169
- // grok-3, grok-4 and grok-4-fast REJECT reasoning_effort outright (HTTP 400
170
- // "does not support parameter reasoningEffort"), so the parameter ships only
171
- // for the families that document it. An unknown/absent model id keeps the
172
- // parameter — a custom xAI-compatible deployment that never saw the 400 must
173
- // not silently lose the caller's effort choice.
169
+ const hasEffortValues = (modelOptions) =>
170
+ (modelOptions || []).some((option) => option?.type === 'effort' && option.values?.length > 0);
171
+
172
+ // The first Grok generations (grok-3, grok-4, grok-4-fast, grok-code-fast)
173
+ // REJECT reasoning_effort outright (HTTP 400 "does not support parameter
174
+ // reasoningEffort"). That generation is closed: it is told apart by a bare
175
+ // major with no minor version, so a later release never matches.
176
+ const LEGACY_NO_EFFORT_GROK = /^grok[-_]?(?:[123]|4|code)(?![.\d])/;
177
+
178
+ // The parameter ships when the catalog says the model takes it. A model the
179
+ // catalog does not list yet is sent the caller's effort (the server decides),
180
+ // as is an unknown/absent model id — a custom xAI-compatible deployment that
181
+ // never saw the 400 must not silently lose the caller's effort choice. Only
182
+ // the closed legacy generation above is held back offline, because sending to
183
+ // it hard-fails.
174
184
  export function xaiModelSupportsReasoningEffort(model) {
175
185
  const id = String(model || '')
176
186
  .trim()
177
187
  .toLowerCase();
178
188
  if (!id) return true;
179
189
  if (!id.startsWith('grok')) return true;
180
- // The catalog answers first: a listed model takes the parameter exactly
181
- // when it publishes effort values (grok-4.3 does, grok-4.20-*-reasoning
182
- // does not), so a new release needs no edit here.
190
+ // The catalog answers first, from the live roster and the pricing catalogs:
191
+ // a model that publishes effort values takes the parameter.
192
+ if (hasEffortValues(getModelMetadataSync(id, 'xai')?.reasoningOptions)) return true;
193
+ // A listed model takes the parameter exactly when it publishes effort
194
+ // values (grok-4.3 does, grok-4.20-*-reasoning does not).
183
195
  const listed = getModelsDevRowSync(id, 'xai');
184
- if (listed) {
185
- return (listed.reasoning_options || []).some((option) => option?.type === 'effort' && option.values?.length > 0);
186
- }
187
- // grok-4.5 / grok-4.6 and anything newer in that line.
188
- return /grok[-_]?4\.(?:[5-9]|\d{2,})/.test(id);
196
+ if (listed) return hasEffortValues(listed.reasoning_options);
197
+ return !LEGACY_NO_EFFORT_GROK.test(id);
189
198
  }
190
199
 
191
200
  function opencodeGoReasoningEffortValues(modelInfo) {
@@ -13,7 +13,9 @@ import {
13
13
  _shouldFallbackXaiWsToHttp,
14
14
  } from './openai-compat-xai.mjs';
15
15
  import { withEffortRejectionFallback } from './effort-rejection.mjs';
16
+ import { applyCompatErrorContract } from './openai-compat-error-contract.mjs';
16
17
  import { sendCompatResponses } from './openai-compat-responses.mjs';
18
+ import { COMPAT_RETRY_POLICY, COMPAT_RETRY_PROFILES, XAI_COMPAT_RETRY_POLICY } from './openai-compat-retry-profile.mjs';
17
19
  import { sendCompatChat, recoverCompatNonStreaming } from './openai-compat-chat-send.mjs';
18
20
  import { sendXaiResponses, sendXaiResponsesWebSocket } from './openai-compat-xai-send.mjs';
19
21
  import {
@@ -71,6 +73,8 @@ export class OpenAICompatProvider {
71
73
  const baseURL = assertSafeBaseURL(config.baseURL || preset?.baseURL || 'http://localhost:8080/v1', name);
72
74
  const apiKey = resolveProviderApiKey(name, config) || 'no-key';
73
75
  this.name = name;
76
+ // A profiled provider's limits are final: no loop-level recovery cycles on top.
77
+ if (COMPAT_RETRY_PROFILES[name]) this.retryPolicy = name === 'xai' ? XAI_COMPAT_RETRY_POLICY : COMPAT_RETRY_POLICY;
74
78
  this.config = config;
75
79
  this.baseURL = baseURL;
76
80
  this.apiKey = apiKey;
@@ -168,9 +172,19 @@ export class OpenAICompatProvider {
168
172
  }
169
173
  async _doSend(messages, model, tools, sendOpts) {
170
174
  const useModel = model || this.defaultModel;
171
- return withEffortRejectionFallback(this.name, useModel, sendOpts || {}, this.config?.reasoningEffort, (opts) =>
172
- this._sendWithOpts(messages, useModel, tools, opts)
173
- );
175
+ try {
176
+ return await withEffortRejectionFallback(
177
+ this.name,
178
+ useModel,
179
+ sendOpts || {},
180
+ this.config?.reasoningEffort,
181
+ (opts) => this._sendWithOpts(messages, useModel, tools, opts)
182
+ );
183
+ } catch (err) {
184
+ // Every provider built on this transport (custom, grok, muse, mimo,
185
+ // command-code, local) leaves through here with the boundary contract.
186
+ throw applyCompatErrorContract(err);
187
+ }
174
188
  }
175
189
  async _sendWithOpts(messages, useModel, tools, opts) {
176
190
  // Re-warm a kept-alive socket to the provider origin before the turn so
@@ -1,33 +1,52 @@
1
1
  import { getModelMetadataSync } from '../../../shared/llm/model-catalog.mjs';
2
2
 
3
- // Public Responses contracts are independent of the OAuth backend.
4
- // Match documented model IDs and dated snapshots, not unknown future families.
5
- // GPT-5.6 and later: prompt_cache_options caching and Fast mode.
6
- const GPT_56_PLUS_MODELS =
3
+ // Public Responses contracts are independent of the OAuth backend. Every
4
+ // capability here comes from the model catalog (the service-tier price columns
5
+ // and the cache-write rate), so a model the catalog lists works without an
6
+ // edit. Only a model no catalog describes (cold install) falls back to the
7
+ // documented ids known before the catalogs carried these columns.
8
+ const SNAPSHOT_SUFFIX = /-\d{4}-\d{2}-\d{2}$/;
9
+ const PRE_CATALOG_GPT_56_PLUS =
7
10
  /^(?:gpt-6-(?:astra|sol|luna)|gpt-6\.1-sol|gpt-5\.6-(?:sol|terra|luna))(?:-\d{4}-\d{2}-\d{2})?$/;
11
+ const PRE_CATALOG_EARLIER_FAST = /^gpt-5\.(?:5|4|4-mini)(?:-\d{4}|$)/;
12
+ const PRE_CATALOG_ULTRAFAST = /^(?:gpt-6-astra|gpt-6\.1-sol)(?:-\d{4}-\d{2}-\d{2})?$/;
13
+ const modelId = (model) => String(model?.id || model || '').trim();
8
14
 
9
- // OpenAI bills prompt-cache writes from GPT-5.6 on, and exactly those models
10
- // take prompt_cache_options and Fast mode. The id pattern is the offline
11
- // floor; a published cache-write price admits a later model without an edit
12
- // here, and a family no catalog lists yet stays out.
13
- // https://developers.openai.com/api/docs/guides/prompt-caching
14
- function isGpt56PlusModel(model) {
15
- const id = String(model || '').trim();
16
- if (!id) return false;
17
- return GPT_56_PLUS_MODELS.test(id) || getModelMetadataSync(id, 'openai')?.cacheWriteCostPerM > 0;
15
+ // The catalog metadata of one model. A dated snapshot reads as its undated
16
+ // alias when only the alias is listed.
17
+ function openAiCatalogMeta(model) {
18
+ const id = String(model?.id || model || '').trim();
19
+ for (const candidate of new Set([id, id.replace(SNAPSHOT_SUFFIX, '')])) {
20
+ const meta = candidate ? getModelMetadataSync(candidate, 'openai') : null;
21
+ if (meta) return meta;
22
+ }
23
+ return null;
18
24
  }
19
- // Earlier models documented for Priority processing (now Fast mode).
20
- const EARLIER_FAST_MODELS = /^gpt-5\.(?:5|4|4-mini)(?:-\d{4}|$)/;
21
25
 
22
26
  export function openAiDirectSupportsFast(model) {
23
- const id = String(model?.id || model || '').trim();
24
- return isGpt56PlusModel(id) || EARLIER_FAST_MODELS.test(id);
27
+ const meta = openAiCatalogMeta(model);
28
+ // The catalog's declaration (true or false) decides; a published Priority
29
+ // rate is the same declaration. A row silent on Fast keeps the documented ids.
30
+ if (typeof meta?.supportsFastMode === 'boolean') return meta.supportsFastMode;
31
+ if (meta?.fastPricing) return true;
32
+ const id = modelId(model);
33
+ return PRE_CATALOG_GPT_56_PLUS.test(id) || PRE_CATALOG_EARLIER_FAST.test(id);
25
34
  }
26
35
 
27
- const ULTRAFAST_MODELS = /^(?:gpt-6-astra|gpt-6\.1-sol)(?:-\d{4}-\d{2}-\d{2})?$/;
28
-
36
+ // A published Ultrafast rate (the `_ultrafast` price columns) is the tier's
37
+ // declaration; a row silent on it keeps the documented ids.
29
38
  export function openAiDirectSupportsUltrafast(model) {
30
- return ULTRAFAST_MODELS.test(String(model?.id || model || '').trim());
39
+ if (openAiCatalogMeta(model)?.ultrafastPricing) return true;
40
+ return PRE_CATALOG_ULTRAFAST.test(modelId(model));
41
+ }
42
+
43
+ // OpenAI bills prompt-cache writes from GPT-5.6 on, and exactly those models
44
+ // take prompt_cache_options in place of prompt_cache_retention, so a published
45
+ // cache-write rate selects the new form.
46
+ // https://developers.openai.com/api/docs/guides/prompt-caching
47
+ function promptCacheForm(model) {
48
+ const writeRate = openAiCatalogMeta(model)?.cacheWriteCostPerM > 0;
49
+ return writeRate || PRE_CATALOG_GPT_56_PLUS.test(modelId(model)) ? 'options' : 'retention';
31
50
  }
32
51
 
33
52
  export function applyOpenAIDirectCachePolicy(body, model, storeResponses) {
@@ -37,10 +56,8 @@ export function applyOpenAIDirectCachePolicy(body, model, storeResponses) {
37
56
  // Preserve the existing opt-out: no explicit cache-retention hint when
38
57
  // response storage is disabled. The provider's default cache still applies.
39
58
  if (!storeResponses) return body;
40
- if (isGpt56PlusModel(model)) {
41
- body.prompt_cache_options = { ttl: '30m' };
42
- } else {
43
- body.prompt_cache_retention = '24h';
44
- }
59
+ const form = promptCacheForm(model);
60
+ if (form === 'options') body.prompt_cache_options = { ttl: '30m' };
61
+ else if (form === 'retention') body.prompt_cache_retention = '24h';
45
62
  return body;
46
63
  }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * openai-error-contract.mjs — the one place the OpenAI provider boundary
3
+ * stamps the shared error contract onto a thrown error:
4
+ *
5
+ * status / httpStatus typed HTTP status
6
+ * code lowercased error code/type from the JSON body or wire event
7
+ * headers response headers (HTTP, WS handshake, nested error.headers)
8
+ * retryAfterMs server advice, uncapped (Retry-After / Retry-After-Ms)
9
+ * resetAt epoch ms from quota reset fields (resets_at, resets_in_seconds)
10
+ *
11
+ * Pure: no I/O. Message text is never parsed for status or code.
12
+ */
13
+ import { retryAfterMsFromError, sleepWithAbort } from './retry-classifier.mjs';
14
+
15
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
16
+
17
+ // Explicit quota / billing refusals: waiting never helps, so no retry layer
18
+ // may treat them as an ordinary rate limit.
19
+ const QUOTA_OR_BILLING_CODE_RE = /quota|billing|usage_limit|usage_not_included/;
20
+
21
+ export function isQuotaOrBillingCode(code) {
22
+ return QUOTA_OR_BILLING_CODE_RE.test(String(code || '').toLowerCase());
23
+ }
24
+
25
+ /**
26
+ * Parse an HTTP error body ONCE into its contract fields. `message` is the
27
+ * body's own message (untruncated); callers truncate for diagnostics.
28
+ */
29
+ export function parseErrorBody(text) {
30
+ let json = null;
31
+ try {
32
+ json = JSON.parse(String(text || ''));
33
+ } catch {}
34
+ const error = isObject(json?.error) ? json.error : isObject(json) ? json : null;
35
+ const rawCode = error?.code ?? error?.type ?? (typeof json?.error === 'string' ? json.error : null);
36
+ const code = typeof rawCode === 'string' && rawCode.trim() ? rawCode.trim().toLowerCase() : '';
37
+ const message = typeof error?.message === 'string' ? error.message : '';
38
+ return { json, error, code, message };
39
+ }
40
+
41
+ function epochMsFrom(value) {
42
+ if (typeof value === 'string' && value.trim() && !Number.isFinite(Number(value))) {
43
+ const parsed = Date.parse(value);
44
+ return Number.isNaN(parsed) ? 0 : parsed;
45
+ }
46
+ const n = Number(value);
47
+ if (!Number.isFinite(n) || n <= 0) return 0;
48
+ // Epoch seconds (the wire form) vs epoch milliseconds.
49
+ return n < 1e12 ? Math.round(n * 1000) : Math.round(n);
50
+ }
51
+
52
+ /** Absolute quota reset time (epoch ms) from the first source carrying one; 0 when none. */
53
+ export function resetAtFrom(...sources) {
54
+ for (const source of sources) {
55
+ if (!isObject(source)) continue;
56
+ const absolute = epochMsFrom(source.resets_at ?? source.reset_at ?? source.resetsAt);
57
+ if (absolute) return absolute;
58
+ const inSeconds = Number(source.resets_in_seconds ?? source.reset_in_seconds);
59
+ if (Number.isFinite(inSeconds) && inSeconds > 0) return Date.now() + Math.round(inSeconds * 1000);
60
+ }
61
+ return 0;
62
+ }
63
+
64
+ // ── User-facing messages for typed wire errors ───────────────────────────────
65
+ const QUOTA_EXCEEDED_MESSAGE = 'Quota exceeded. Check your plan and billing details.';
66
+ const USAGE_NOT_INCLUDED_MESSAGE =
67
+ 'To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus.';
68
+ const SERVER_OVERLOADED_MESSAGE = 'Selected model is at capacity. Please try a different model.';
69
+ const WS_CONNECTION_LIMIT_MESSAGE =
70
+ 'Responses websocket connection limit reached (60 minutes). Create a new websocket connection to continue.';
71
+ const PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE = 'Previous response was not found. Retrying the full request.';
72
+ const QUOTA_EXCEEDED_CODES = new Set([
73
+ 'insufficient_quota',
74
+ 'credit_balance_exhausted',
75
+ 'organization_spend_limit_exceeded',
76
+ 'project_spend_limit_exceeded',
77
+ 'organization_usage_limit_exceeded',
78
+ ]);
79
+ // Wire codes whose own message is empty fall back to this copy.
80
+ const FALLBACK_MESSAGES = Object.freeze({
81
+ websocket_connection_limit_reached: WS_CONNECTION_LIMIT_MESSAGE,
82
+ previous_response_not_found: PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE,
83
+ });
84
+
85
+ function headerValue(headers, name) {
86
+ if (!headers || typeof headers !== 'object') return '';
87
+ if (typeof headers.get === 'function') return String(headers.get(name) ?? '').trim();
88
+ const lower = name.toLowerCase();
89
+ for (const [key, value] of Object.entries(headers)) {
90
+ if (key.toLowerCase() === lower) return String(Array.isArray(value) ? value[0] : (value ?? '')).trim();
91
+ }
92
+ return '';
93
+ }
94
+
95
+ function ordinalSuffix(day) {
96
+ if (day >= 11 && day <= 13) return 'th';
97
+ return { 1: 'st', 2: 'nd', 3: 'rd' }[day % 10] || 'th';
98
+ }
99
+
100
+ /** Retry timestamp: "3:04 PM" today, else "Mar 5th, 2026 3:04 PM" (local time). */
101
+ export function formatRetryTimestamp(resetsAtMs, now = Date.now()) {
102
+ const reset = new Date(resetsAtMs);
103
+ const time = reset.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
104
+ if (reset.toDateString() === new Date(now).toDateString()) return time;
105
+ const month = reset.toLocaleDateString('en-US', { month: 'short' });
106
+ return `${month} ${reset.getDate()}${ordinalSuffix(reset.getDate())}, ${reset.getFullYear()} ${time}`;
107
+ }
108
+
109
+ const WORKSPACE_REACHED_MESSAGES = Object.freeze({
110
+ workspace_owner_credits_depleted: 'Your workspace is out of credits. Add credits to continue.',
111
+ workspace_member_credits_depleted:
112
+ 'Your workspace is out of credits. Ask your workspace owner to refill in order to continue.',
113
+ workspace_owner_usage_limit_reached:
114
+ 'You hit your spend cap set in your workspace. Increase your spend cap to continue.',
115
+ workspace_member_usage_limit_reached:
116
+ 'You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue.',
117
+ });
118
+ const PRO_PLANS = new Set(['pro', 'prolite', 'promax']);
119
+ const ADMIN_PLANS = new Set([
120
+ 'team',
121
+ 'self_serve_business_prolite',
122
+ 'self_serve_business_usage_based',
123
+ 'business',
124
+ 'ent26',
125
+ 'enterprise_cbp_automation',
126
+ 'enterprise_cbp_usage_based',
127
+ ]);
128
+
129
+ /**
130
+ * Usage-limit copy: per-limit name first, then workspace
131
+ * credits / spend cap, then the promo message, then the plan upgrade copy.
132
+ * @param {{planType?: string, resetsAtMs?: number, promo?: string, reachedType?: string, limitName?: string, now?: number}} f
133
+ */
134
+ export function usageLimitMessage({ planType, resetsAtMs, promo, reachedType, limitName, now } = {}) {
135
+ const at = resetsAtMs ? formatRetryTimestamp(resetsAtMs, now) : '';
136
+ const suffix = at ? ` Try again at ${at}.` : ' Try again later.';
137
+ const suffixOr = at ? ` or try again at ${at}.` : ' or try again later.';
138
+ const name = String(limitName || '').trim();
139
+ if (name && !/^(codex|gpt-reserve)$/i.test(name)) {
140
+ return `You’ve hit your usage limit for ${name}. Switch to another model now,${suffixOr}`;
141
+ }
142
+ const workspace = WORKSPACE_REACHED_MESSAGES[String(reachedType || '').toLowerCase()];
143
+ if (workspace) return workspace;
144
+ if (promo) return `You’ve hit your usage limit. ${promo},${suffixOr}`;
145
+ const plan = String(planType || '').toLowerCase();
146
+ if (plan === 'plus') {
147
+ return `You’ve hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/settings/usage to purchase more credits${suffixOr}`;
148
+ }
149
+ if (ADMIN_PLANS.has(plan)) {
150
+ return `You’ve hit your usage limit. To get more access now, send a request to your admin${suffixOr}`;
151
+ }
152
+ if (plan === 'free' || plan === 'go') {
153
+ return `You’ve hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus),${suffixOr}`;
154
+ }
155
+ if (PRO_PLANS.has(plan)) {
156
+ return `You’ve hit your usage limit. Visit https://chatgpt.com/settings/usage to purchase more credits${suffixOr}`;
157
+ }
158
+ return `You’ve hit your usage limit.${suffix}`;
159
+ }
160
+
161
+ /** The user-facing copy for a typed wire error, or '' when none applies. */
162
+ function typedWireErrorMessage({ code, type, error, headers, resetAt }) {
163
+ if (type === 'usage_limit_reached' || code === 'usage_limit_reached') {
164
+ const limitId = headerValue(headers, 'x-codex-active-limit').toLowerCase().replace(/_/g, '-');
165
+ return usageLimitMessage({
166
+ planType: error?.plan_type,
167
+ resetsAtMs: resetAt,
168
+ promo: headerValue(headers, 'x-codex-promo-message'),
169
+ reachedType: headerValue(headers, 'x-codex-rate-limit-reached-type'),
170
+ limitName: limitId ? headerValue(headers, `x-${limitId}-limit-name`) : '',
171
+ });
172
+ }
173
+ if (type === 'usage_not_included' || code === 'usage_not_included') return USAGE_NOT_INCLUDED_MESSAGE;
174
+ if (QUOTA_EXCEEDED_CODES.has(code) || type === 'insufficient_quota') return QUOTA_EXCEEDED_MESSAGE;
175
+ if (code === 'server_is_overloaded') return SERVER_OVERLOADED_MESSAGE;
176
+ const fallback = FALLBACK_MESSAGES[code];
177
+ return fallback && !String(error?.message || '').trim() ? fallback : '';
178
+ }
179
+
180
+ // Swap the server's own text inside `err.message` for the mapped copy; the
181
+ // original stays on providerError. `userMessage` always carries the copy.
182
+ function applyTypedWireMessage(err, mapped, original) {
183
+ err.userMessage = mapped;
184
+ const current = String(err.message || '');
185
+ const raw = String(original || '');
186
+ for (const needle of [raw, raw.slice(0, 200)]) {
187
+ if (needle && current.endsWith(needle)) {
188
+ err.message = current.slice(0, current.length - needle.length) + mapped;
189
+ return;
190
+ }
191
+ }
192
+ if (!raw && current) err.message = `${current}: ${mapped}`;
193
+ }
194
+ /**
195
+ * Stamp the contract onto `err`. Every field is optional input; only the
196
+ * ones present are written, and a field already on `err` is never replaced
197
+ * by an absent one.
198
+ *
199
+ * @param {Error} err
200
+ * @param {object} src
201
+ * @param {number} [src.status]
202
+ * @param {object} [src.headers] Headers or plain object
203
+ * @param {string} [src.code] already-lowercased code
204
+ * @param {object} [src.error] parsed error object (code/type/resets_at/headers)
205
+ * @param {object} [src.event] wire event the error was built from
206
+ */
207
+ export function applyErrorContract(err, { status, headers, code, error, event } = {}) {
208
+ try {
209
+ const n = Number(status);
210
+ if (Number.isFinite(n) && n >= 100 && n <= 599) {
211
+ err.status = n;
212
+ err.httpStatus = n;
213
+ }
214
+ const wireHeaders = headers ?? error?.headers ?? event?.error?.headers ?? event?.response?.error?.headers ?? event?.headers;
215
+ if (wireHeaders && typeof wireHeaders === 'object') err.headers = wireHeaders;
216
+ const rawCode = code ?? error?.code ?? error?.type ?? null;
217
+ if (typeof rawCode === 'string' && rawCode.trim()) err.code = rawCode.trim().toLowerCase();
218
+ const resetAt = resetAtFrom(error, event?.error, event?.response?.error, event);
219
+ if (resetAt) err.resetAt = resetAt;
220
+ const retryAfterMs = retryAfterMsFromError(err);
221
+ if (retryAfterMs != null) {
222
+ err.retryAfterMs = retryAfterMs;
223
+ // Server advice is a deadline captured once, so a
224
+ // later transport switch cannot restart or skip it.
225
+ if (err.retryAfterAt == null) err.retryAfterAt = Date.now() + retryAfterMs;
226
+ }
227
+ const type = typeof error?.type === 'string' ? error.type.trim().toLowerCase() : '';
228
+ const mapped = typedWireErrorMessage({ code: err.code || '', type, error, headers: err.headers, resetAt: err.resetAt });
229
+ if (mapped) applyTypedWireMessage(err, mapped, error?.message);
230
+ } catch {}
231
+ return err;
232
+ }
233
+
234
+ /**
235
+ * A response.incomplete with reason `content_filter` is a policy refusal:
236
+ * type it so no layer mistakes it for a transient or untyped failure. The
237
+ * wire event is deliberately NOT attached as `responseFailed` (that marker
238
+ * makes unknown codes default-retryable).
239
+ */
240
+ export function markContentFilter(err, reason) {
241
+ if (String(reason || '').trim().toLowerCase() !== 'content_filter') return err;
242
+ err.code = 'content_filter';
243
+ err.providerErrorCode = 'content_filter';
244
+ return err;
245
+ }
246
+
247
+ /**
248
+ * The error for a non-2xx HTTP response whose body has been read as `text`.
249
+ * The message carries a bounded diagnostic; the structured fields come from
250
+ * the full parsed body.
251
+ */
252
+ export function httpResponseError({ label, status, headers, text, initialResponse = false, diagnosticLimit = 200 }) {
253
+ const parsed = parseErrorBody(text);
254
+ const diagnostic = (parsed.message || String(text || '')).slice(0, diagnosticLimit);
255
+ const err = new Error(diagnostic ? `${label} ${status}: ${diagnostic}` : `${label} ${status}`);
256
+ if (initialResponse) err.initialResponseError = true;
257
+ if (parsed.error) err.providerError = parsed.error;
258
+ if (parsed.code) err.providerErrorCode = parsed.code;
259
+ return applyErrorContract(err, { status, headers, code: parsed.code, error: parsed.error, event: parsed.json });
260
+ }
261
+
262
+ // The route decides the HTTP/SSE label: the api-key `openai` provider is not OAuth.
263
+ export const httpLabel = (auth) => (auth?.type === 'openai-direct' ? 'OpenAI HTTP fallback' : 'OpenAI OAuth HTTP fallback');
264
+
265
+ /**
266
+ * Changing transport must not bypass the server's retry deadline.
267
+ * Sleeps until the deadline the failed attempt captured (abortable).
268
+ */
269
+ export async function waitForRetryDeadline(err, externalSignal, sleepFn) {
270
+ // retryAfterMsFromError reports what is left of the captured retryAfterAt.
271
+ const remaining = retryAfterMsFromError(err) ?? 0;
272
+ if (!(remaining > 0)) return;
273
+ await sleepWithAbort(remaining, externalSignal, sleepFn, 'OpenAI transport fallback wait aborted');
274
+ }
@@ -9,11 +9,17 @@
9
9
  import zlib from 'node:zlib';
10
10
  import { traceAgentFetch } from '../agent-trace.mjs';
11
11
  import { PROVIDER_HTTP_RESPONSE_TIMEOUT_MS, createTimeoutSignal } from '../stall-policy.mjs';
12
+ import { CODEX_REQUEST_RETRY_BACKOFF_MS, CODEX_RETRY_JITTER_RATIO } from './openai-retry-policy.mjs';
12
13
  import { classifyError, jitterDelayMs, retryAfterMsFromError, sleepWithAbort } from './retry-classifier.mjs';
14
+ import { emitProviderRetryStage } from './retry-executor.mjs';
15
+ import { adviceGatedRetry } from './retry-midstream.mjs';
16
+ import { TERMINAL_EDGE_STATUSES } from './retry-classification.mjs';
17
+ import { DETERMINISTIC_KINDS, providerErrorKind } from './provider-error-kind.mjs';
13
18
  import { readStreamOutcome } from './lib/stream-outcome.mjs';
14
19
  import { getLlmDispatcher, recycleLlmDispatcher } from '../../../shared/llm/http-agent.mjs';
15
20
  import { CODEX_RESPONSES_URL } from './openai-codex-endpoints.mjs';
16
21
  import { captureCodexTurnState } from './openai-turn-state.mjs';
22
+ import { httpLabel, httpResponseError } from './openai-error-contract.mjs';
17
23
 
18
24
  // Public OpenAI Responses API endpoint for the api-key `openai` provider.
19
25
  // The openai-direct WS transport hits the same origin (openai-ws-pool
@@ -22,9 +28,28 @@ import { captureCodexTurnState } from './openai-turn-state.mjs';
22
28
  // openai-oauth. Same Responses SSE wire format, only endpoint + auth differ.
23
29
  const OPENAI_DIRECT_RESPONSES_URL = 'https://api.openai.com/v1/responses';
24
30
  const CODEX_REQUEST_MAX_RETRIES = 4;
25
- const CODEX_REQUEST_BACKOFF_MS = Object.freeze([200, 400, 800, 1600]);
26
- const CODEX_RETRY_JITTER_RATIO = 0.1;
31
+ // Advice-gated retry for the OpenAI request loops (same options the shared
32
+ // withRetry takes): an overload and an untyped 429 retry only with server advice.
33
+ const OPENAI_REQUEST_RETRY_OPTS = Object.freeze({
34
+ overloadRetryRequiresAdvice: true,
35
+ generic429RetryRequiresAdvice: true,
36
+ backoffMs: CODEX_REQUEST_RETRY_BACKOFF_MS,
37
+ retryJitterRatio: CODEX_RETRY_JITTER_RATIO,
38
+ });
27
39
 
40
+
41
+ // Request-layer retry: 5xx and typed transport
42
+ // failures only. 408 and 429 are NOT retried here; they surface to the outer
43
+ // stream-retry layer, which owns their budget and honors Retry-After.
44
+ // A deterministic refusal (quota, billing, entitlement, policy, auth,
45
+ // permission, invalid request) is terminal whatever status carries it.
46
+ function isRetryableHttpFailure(err) {
47
+ const status = err.httpStatus;
48
+ if (!(status >= 500 && status <= 599) || TERMINAL_EDGE_STATUSES.has(status)) return false;
49
+ const { kind } = providerErrorKind(err);
50
+ if (DETERMINISTIC_KINDS.has(kind)) return false;
51
+ return adviceGatedRetry(err, OPENAI_REQUEST_RETRY_OPTS) !== false;
52
+ }
28
53
  // Request-body zstd gate. Namespace import: on Node runtimes without zlib
29
54
  // zstd bindings the named export would fail at module load, so the call site
30
55
  // typeof-guards zlib.zstdCompressSync instead.
@@ -65,13 +90,15 @@ async function postWithRetries({
65
90
  onStageChange,
66
91
  _sleepFn,
67
92
  }) {
93
+ const label = httpLabel(auth);
68
94
  let bodyForSend = encodeRequestBody(auth, rawBytes);
69
95
  let response;
96
+ let httpError = null;
70
97
  for (let attempt = 0; attempt <= CODEX_REQUEST_MAX_RETRIES; attempt++) {
71
98
  const headerTimeout = createTimeoutSignal(
72
99
  totalTimeout.signal,
73
100
  PROVIDER_HTTP_RESPONSE_TIMEOUT_MS,
74
- 'OpenAI OAuth HTTP fallback initial response'
101
+ `${label} initial response`
75
102
  );
76
103
  let requestError = null;
77
104
  try {
@@ -113,7 +140,19 @@ async function postWithRetries({
113
140
  attempt--;
114
141
  continue;
115
142
  }
116
- const retryableStatus = response && response.status >= 500 && response.status <= 599;
143
+ // The error body is read exactly once, here, into the contract fields.
144
+ httpError = null;
145
+ if (response && !response.ok) {
146
+ const text = await response.text().catch(() => '');
147
+ httpError = httpResponseError({
148
+ label,
149
+ status: response.status,
150
+ headers: response.headers,
151
+ text,
152
+ initialResponse: true,
153
+ });
154
+ }
155
+ const retryableStatus = httpError !== null && isRetryableHttpFailure(httpError);
117
156
  // Typed transient transport failures only (errno / SDK connection
118
157
  // type). An unknown pre-response failure throws immediately instead of
119
158
  // re-issuing the POST.
@@ -135,30 +174,36 @@ async function postWithRetries({
135
174
  // Reissuing the POST is a REPLAY: allowed for a typed transient
136
175
  // failure of the initial request, denied once the failure carries
137
176
  // exposure evidence (relayed output / dispatched tool call).
138
- const attemptFailure =
139
- requestError ||
140
- Object.assign(new Error(`OpenAI OAuth HTTP fallback ${response.status}`), {
141
- httpStatus: response.status,
142
- headers: response.headers,
143
- initialResponseError: true,
144
- });
177
+ const attemptFailure = requestError || httpError;
145
178
  if (readStreamOutcome(attemptFailure).replaySafe !== true) {
146
- if (response) await response.arrayBuffer().catch(() => {});
147
179
  totalTimeout.cleanup();
148
180
  throw attemptFailure;
149
181
  }
150
- // A non-success response has not exposed any streamed output. Drain
151
- // its body before reissuing so the dispatcher can reuse the socket.
152
- if (response) await response.arrayBuffer().catch(() => {});
153
182
  // Server advice (Retry-After / Retry-After-Ms) replaces the local curve.
154
- const raw = CODEX_REQUEST_BACKOFF_MS[attempt];
155
- await sleepWithAbort(
156
- retryAfterMsFromError(attemptFailure) ?? jitterDelayMs(raw, CODEX_RETRY_JITTER_RATIO),
157
- externalSignal,
158
- _sleepFn,
159
- 'OpenAI OAuth HTTP request retry backoff aborted'
160
- );
183
+ // Uncapped for retryable errors; the attempt count is
184
+ // the bound, and the abort signal cancels the wait.
185
+ // The shared schedule carries a leading duplicate (withRetry convention).
186
+ const raw = OPENAI_REQUEST_RETRY_OPTS.backoffMs[attempt + 1];
187
+ const delayMs = retryAfterMsFromError(attemptFailure) ?? jitterDelayMs(raw, OPENAI_REQUEST_RETRY_OPTS.retryJitterRatio);
188
+ emitProviderRetryStage(onStageChange, {
189
+ attempt: attempt + 2,
190
+ maxAttempts: CODEX_REQUEST_MAX_RETRIES + 1,
191
+ lastErr: attemptFailure,
192
+ delayMs,
193
+ });
194
+ try {
195
+ await sleepWithAbort(
196
+ delayMs,
197
+ externalSignal || totalTimeout.signal,
198
+ _sleepFn,
199
+ `${label} request retry backoff aborted`
200
+ );
201
+ } catch (abortErr) {
202
+ totalTimeout.cleanup();
203
+ throw abortErr;
204
+ }
161
205
  response = undefined;
206
+ httpError = null;
162
207
  continue;
163
208
  }
164
209
  if (requestError) {
@@ -169,7 +214,7 @@ async function postWithRetries({
169
214
  }
170
215
  break;
171
216
  }
172
- return response;
217
+ return { response, httpError };
173
218
  }
174
219
 
175
220
  export async function openHttpSseResponse({
@@ -187,7 +232,7 @@ export async function openHttpSseResponse({
187
232
  }) {
188
233
  const fetchStartedAt = Date.now();
189
234
  const responsesUrl = auth?.type === 'openai-direct' ? OPENAI_DIRECT_RESPONSES_URL : CODEX_RESPONSES_URL;
190
- const response = await postWithRetries({
235
+ const { response, httpError } = await postWithRetries({
191
236
  responsesUrl,
192
237
  headers,
193
238
  rawBytes: Buffer.from(JSON.stringify(body)),
@@ -208,14 +253,9 @@ export async function openHttpSseResponse({
208
253
  transport: 'http',
209
254
  });
210
255
 
211
- if (!response.ok) {
212
- const text = await response.text().catch(() => '');
213
- const err = new Error(`OpenAI OAuth HTTP fallback ${response.status}: ${text.slice(0, 200)}`);
214
- err.httpStatus = response.status;
215
- err.headers = response.headers;
216
- err.initialResponseError = true;
256
+ if (httpError) {
217
257
  totalTimeout.cleanup();
218
- throw err;
258
+ throw httpError;
219
259
  }
220
260
  if (auth?.type !== 'openai-direct') {
221
261
  const responseTurnState = response.headers?.get?.('x-codex-turn-state');
@@ -223,7 +263,7 @@ export async function openHttpSseResponse({
223
263
  }
224
264
  if (!response.body) {
225
265
  totalTimeout.cleanup();
226
- throw Object.assign(new Error('OpenAI OAuth HTTP fallback returned no response body'), {
266
+ throw Object.assign(new Error(`${httpLabel(auth)} returned no response body`), {
227
267
  initialResponseError: true,
228
268
  });
229
269
  }