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
@@ -7,17 +7,16 @@ import { noteErrorUsage } from './lib/note-error-usage.mjs';
7
7
  import { retryReasonText, retryStatusLine } from '../../../shared/err-text.mjs';
8
8
  import {
9
9
  TERMINAL_EDGE_STATUSES,
10
- headerValue,
11
10
  boundedCauseChain,
12
11
  classifyError,
13
- isPermanentQuotaError,
14
12
  isStaleKeepAliveError,
15
13
  isToolInputCut,
16
14
  retryAfterMsFromError,
17
15
  } from './retry-classification.mjs';
16
+ import { DETERMINISTIC_KINDS, QUOTA_KINDS, errorHeaderValue, providerErrorKind } from './provider-error-kind.mjs';
17
+ import { adviceGatedRetry } from './retry-midstream.mjs';
18
18
  import {
19
- ANTHROPIC_MAX_CONSECUTIVE_529,
20
- AnthropicFallbackTriggeredError,
19
+ ModelFallbackTriggeredError,
21
20
  DEFAULT_BACKOFF_MS,
22
21
  DEFAULT_MAX_ATTEMPTS,
23
22
  jitterDelayMs,
@@ -64,11 +63,16 @@ export function createStreamSafetyStamps() {
64
63
 
65
64
  const providerRecoveryExhaustedErrors = new WeakSet();
66
65
 
67
- export function markProviderRecoveryExhausted(error, { owner = 'provider', attempts = null } = {}) {
66
+ // `terminal: true` records a provider VERDICT that recovery is over (a policy
67
+ // veto such as an unadvised advice-gated overload, or the repeated-overload
68
+ // stop), not merely a spent ladder: outer layers end recovery on it instead
69
+ // of opening another budget.
70
+ export function markProviderRecoveryExhausted(error, { owner = 'provider', attempts = null, terminal = false } = {}) {
68
71
  if (!error || (typeof error !== 'object' && typeof error !== 'function')) return error;
69
72
  providerRecoveryExhaustedErrors.add(error);
70
73
  try {
71
74
  error.providerRecoveryExhausted = true;
75
+ if (terminal) error.providerRecoveryTerminal = true;
72
76
  if (owner) error.providerRecoveryOwner = String(owner);
73
77
  const count = Number(attempts);
74
78
  if (Number.isFinite(count) && count > 0) error.providerRecoveryAttempts = Math.floor(count);
@@ -85,18 +89,25 @@ export function isProviderRecoveryExhausted(error) {
85
89
  );
86
90
  }
87
91
 
92
+ /** True when a provider layer ruled that no further recovery may run (see markProviderRecoveryExhausted). */
93
+ export function isProviderRecoveryTerminal(error) {
94
+ if (!error || (typeof error !== 'object' && typeof error !== 'function')) return false;
95
+ return boundedCauseChain(error).some((candidate) => candidate?.providerRecoveryTerminal === true);
96
+ }
97
+
88
98
  function throwIfRetryAborted(signal) {
89
99
  if (!signal?.aborted) return;
90
100
  const reason = signal.reason;
91
101
  throw reason instanceof Error ? reason : new Error('withRetry: aborted');
92
102
  }
93
103
 
94
- // A stale keep-alive socket, or (Grok) the first 5xx, rebuilds the HTTP
95
- // client to escape a poisoned HTTP/2 keep-alive pool (CF 522/523/524
96
- // included). 525/526 stay fatal.
97
- function recycleDispatcherAfter(caught, status) {
104
+ // Before a retry: a stale keep-alive socket rebuilds the HTTP client to escape
105
+ // a poisoned keep-alive pool. A caller whose upstream poisons its HTTP/2 pool
106
+ // on server faults (`recycleDispatcherOn5xx`) also rebuilds it on a retried
107
+ // 5xx (edge 522/523/524 included). Never runs for a failure that is not retried.
108
+ function recycleDispatcherAfter(caught, status, opts) {
98
109
  const poisoned =
99
- isStaleKeepAliveError(caught) || (status >= 500 && status < 600 && !TERMINAL_EDGE_STATUSES.has(status));
110
+ isStaleKeepAliveError(caught) || (opts.recycleDispatcherOn5xx === true && status >= 500 && status < 600);
100
111
  if (!poisoned) return;
101
112
  try {
102
113
  recycleLlmDispatcher();
@@ -105,23 +116,71 @@ function recycleDispatcherAfter(caught, status) {
105
116
  }
106
117
  }
107
118
 
119
+ // Opt-in: without a fallback model, the request loop stops after
120
+ // `overloadFallbackAfter` overloads instead of spending the whole
121
+ // attempt budget. A provider verdict: no outer layer re-opens recovery.
122
+ function repeated529Error(caught, opts, maxAttempts) {
123
+ const error = new Error('Repeated 529 Overloaded errors', { cause: caught });
124
+ error.name = 'Repeated529Error';
125
+ error.repeated529 = true;
126
+ error.status = Number(caught?.status || caught?.httpStatus || 529) || 529;
127
+ error.httpStatus = error.status;
128
+ error.code = 'overloaded_error';
129
+ if (caught?.headers) error.headers = caught.headers;
130
+ return markProviderRecoveryExhausted(error, {
131
+ owner: opts.recoveryOwner || 'withRetry',
132
+ attempts: maxAttempts,
133
+ terminal: true,
134
+ });
135
+ }
136
+
137
+ // Deterministic refusals of this request (auth, permission, policy, invalid
138
+ // request, context/size) end the loop before any status, header or overload
139
+ // rule may re-issue it. Quota kinds are judged separately below.
140
+ function deterministicRefusal(errorKind, kind, status, opts) {
141
+ if (!DETERMINISTIC_KINDS.has(errorKind) || QUOTA_KINDS.has(errorKind)) return false;
142
+ // Opt-in: a status-only 499 is retried (classifyError's retry499 verdict;
143
+ // a typed code on the 499 keeps it final).
144
+ return !(status === 499 && opts.retry499 === true && kind === 'transient');
145
+ }
146
+
147
+ // The overload counter (`overloadFallbackAfter`) counts every overload of the
148
+ // send; intervening non-overload failures do not reset it.
149
+ function isOverloadFailure(status, errorKind) {
150
+ return status === 529 || errorKind === 'overloaded';
151
+ }
152
+
108
153
  // Decides whether `caught` may be re-issued: returns the error to throw (the
109
154
  // caught one, the exhausted-marked one on the last attempt, or the model
110
- // fallback trigger) or null when the next attempt may run.
111
- function retryVeto(caught, { opts, attempt, maxAttempts, state }) {
155
+ // fallback trigger) or null when the next attempt may run — and only then
156
+ // rebuilds a poisoned HTTP pool for it.
157
+ function retryVeto(caught, context) {
158
+ const verdict = retryVerdict(caught, context);
159
+ if (verdict === null) {
160
+ const status = Number(caught?.httpStatus || caught?.status || caught?.response?.status || 0);
161
+ recycleDispatcherAfter(caught, status, context.opts);
162
+ }
163
+ return verdict;
164
+ }
165
+
166
+ function retryVerdict(caught, { opts, attempt, maxAttempts, state }) {
112
167
  const lastAttempt = attempt === maxAttempts - 1;
168
+ // `finalOnExhaust`: the caller's ladder is the whole recovery, so a spent
169
+ // ladder is a provider-terminal verdict no outer layer re-opens.
113
170
  const exhaustedError = () =>
114
- maxAttempts > 1
171
+ maxAttempts > 1 || opts.finalOnExhaust === true
115
172
  ? markProviderRecoveryExhausted(caught, {
116
173
  owner: opts.recoveryOwner || 'withRetry',
117
174
  attempts: maxAttempts,
175
+ terminal: opts.finalOnExhaust === true,
118
176
  })
119
177
  : caught;
120
178
  // A nested provider layer already spent its complete recovery budget.
121
179
  // Never multiply that budget by replaying it from an outer withRetry.
122
180
  if (isProviderRecoveryExhausted(caught)) return caught;
123
181
  const status = Number(caught?.httpStatus || caught?.status || caught?.response?.status || 0);
124
- const kind = classifyError(caught);
182
+ const kind = classifyError(caught, { retry499: opts.retry499 === true });
183
+ const errorKind = providerErrorKind(caught).kind;
125
184
  // Hard replay boundary: a retry RE-ISSUES the request, so it is denied
126
185
  // once visible output was relayed or a tool call was dispatched. Whether
127
186
  // an eligible failure is actually retried remains the typed question
@@ -131,40 +190,67 @@ function retryVeto(caught, { opts, attempt, maxAttempts, state }) {
131
190
  // split-call notice; re-issuing the identical request here would tend to be
132
191
  // cut at the same place again.
133
192
  if (isToolInputCut(caught)) return caught;
134
- recycleDispatcherAfter(caught, status);
193
+ const overload = isOverloadFailure(status, errorKind);
194
+ // Deterministic refusals first: no status, header (x-should-retry:true
195
+ // included) or overload rule below may re-issue them.
196
+ if (deterministicRefusal(errorKind, kind, status, opts)) return caught;
197
+ // An origin-TLS edge failure (525/526) is final whatever header, advice or
198
+ // overload code accompanies it.
199
+ if (TERMINAL_EDGE_STATUSES.has(status)) return caught;
200
+ // An explicit quota / plan / billing refusal cannot recover by replaying
201
+ // the same request, whatever Retry-After the server attached. A 429 whose
202
+ // only quota evidence is header-derived (providerQuota / quotaExceeded) is
203
+ // still retried for non-subscriber callers (Enterprise/PAYG
204
+ // 429s); a typed quota/billing code never is.
205
+ if (QUOTA_KINDS.has(errorKind)) {
206
+ const typedQuota = QUOTA_KINDS.has(providerErrorKind(caught, { quotaFlags: false }).kind);
207
+ if (typedQuota || opts.retry429 === false || status !== 429) return caught;
208
+ }
135
209
  // Background calls (titles and the like) never retry an overload: during a
136
210
  // capacity cascade every retry multiplies gateway load, and nobody is
137
211
  // waiting on the result.
138
212
  if (status === 529 && opts.retry529 === false) return caught;
139
- // x-should-retry:false is an explicit server veto on retrying and is
140
- // honored as-is. Keep this ahead of status defaults, including the
141
- // request-local 429 path.
142
- const shouldRetryHeader = String(
143
- headerValue(caught?.headers || caught?.response?.headers || caught?.data?.responseHeaders, 'x-should-retry') || ''
144
- ).toLowerCase();
145
- if (shouldRetryHeader === 'false') return caught;
146
- // Anthropic's non-standard positive override outranks ordinary status
147
- // classification. Keep subscription OAuth 429 fail-fast ownership:
148
- // retry429:false is the Max/Pro gate and must not wait for that window.
149
- if (opts.provider === 'anthropic' && shouldRetryHeader === 'true' && !(status === 429 && opts.retry429 === false)) {
150
- return lastAttempt ? exhaustedError() : null;
213
+ // A caller may opt into advice-gated overload / generic-429 retries. The
214
+ // denial is the provider's verdict: outer recovery layers honor it.
215
+ if (adviceGatedRetry(caught, opts) === false) {
216
+ return markProviderRecoveryExhausted(caught, {
217
+ owner: opts.recoveryOwner || 'withRetry',
218
+ attempts: attempt + 1,
219
+ terminal: true,
220
+ });
151
221
  }
152
- // The optional model fallback fires on the third 529. This remains opt-in:
153
- // providers pass fallbackModel only when the caller set one. The hard
154
- // progress veto above must run first so fallback can never replay partial
155
- // thinking/tool output.
156
- if (status === 529 && opts.fallbackModel && opts.fallbackModel !== opts.model) {
222
+ // Overload first: counted and retried ahead of the x-should-retry veto.
223
+ // The optional model fallback fires after `overloadFallbackAfter`
224
+ // overloads in the send (opt-in: providers pass fallbackModel only when
225
+ // the caller set one); without one, `stopAfterRepeated529` ends the loop
226
+ // there instead. The hard progress veto above runs first so neither can
227
+ // replay partial thinking/tool output. `overloadState` shares the count
228
+ // across a send.
229
+ if (overload) {
230
+ // Subscriber 429 fail-fast ownership also covers an overload-coded 429.
231
+ if (status === 429 && opts.retry429 === false) return caught;
157
232
  state.consecutive529Errors += 1;
158
- if (state.consecutive529Errors >= ANTHROPIC_MAX_CONSECUTIVE_529) {
159
- return new AnthropicFallbackTriggeredError(opts.model, opts.fallbackModel, caught);
233
+ const limit = Number(opts.overloadFallbackAfter);
234
+ if (Number.isFinite(limit) && limit > 0 && state.consecutive529Errors >= limit) {
235
+ if (opts.fallbackModel && opts.fallbackModel !== opts.model) {
236
+ return new ModelFallbackTriggeredError(opts.model, opts.fallbackModel, caught);
237
+ }
238
+ if (opts.stopAfterRepeated529 === true) return repeated529Error(caught, opts, maxAttempts);
160
239
  }
240
+ return lastAttempt ? exhaustedError() : null;
241
+ }
242
+ // x-should-retry:false is an explicit server veto on retrying and is
243
+ // honored as-is, ahead of status defaults including the 429 path.
244
+ const shouldRetryHeader = String(errorHeaderValue(caught, 'x-should-retry') || '').toLowerCase();
245
+ if (shouldRetryHeader === 'false') return caught;
246
+ // A transport that opts in (honorShouldRetryTrue) lets the non-standard
247
+ // positive override outrank ordinary status classification — except for
248
+ // subscribers (retry429:false), whose fail-fast ownership holds for every status.
249
+ if (opts.honorShouldRetryTrue === true && shouldRetryHeader === 'true' && opts.retry429 !== false) {
250
+ return lastAttempt ? exhaustedError() : null;
161
251
  }
162
252
  if (status === 429) {
163
253
  if (opts.retry429 === false) return caught;
164
- // A deterministic quota refusal cannot recover by replaying the same
165
- // request. An explicit server retry window outranks message-text quota
166
- // heuristics: RESOURCE_EXHAUSTED + Retry-After/RetryInfo is transient.
167
- if (retryAfterMsFromError(caught) == null && isPermanentQuotaError(caught)) return caught;
168
254
  // Retry only this request. Admission concurrency is fixed and is never
169
255
  // reduced by rate limits. Output/tool stamps above remain a hard replay
170
256
  // boundary.
@@ -184,8 +270,12 @@ function retryOptions(opts) {
184
270
  perAttemptTimeoutMs: Number(opts.perAttemptTimeoutMs || 0),
185
271
  perAttemptLabel: opts.perAttemptLabel || 'provider request',
186
272
  retryJitterRatio: Number(opts.retryJitterRatio ?? PROVIDER_RETRY_JITTER_RATIO),
187
- retryJitterMode: opts.retryJitterMode === 'positive' ? 'positive' : 'symmetric',
273
+ retryJitterMode: ['positive', 'negative'].includes(opts.retryJitterMode) ? opts.retryJitterMode : 'symmetric',
188
274
  sleepFn: typeof opts.sleepFn === 'function' ? opts.sleepFn : undefined,
275
+ // Opt-in: positive jitter on server advice, and advice as a
276
+ // floor under — never a replacement for — the current backoff step.
277
+ retryAfterJitterRatio: Math.max(0, Number(opts.retryAfterJitterRatio) || 0),
278
+ adviceAtLeastBackoff: opts.adviceAtLeastBackoff === true,
189
279
  };
190
280
  }
191
281
 
@@ -193,11 +283,17 @@ function retryOptions(opts) {
193
283
  // the jittered backoff step. Retry-After is a server-mandated minimum. Do
194
284
  // not cap, shorten, or jitter it; cancellation remains active throughout
195
285
  // the full wait.
196
- function retryWaitMs(attempt, { nextDelayMs, nextDelayReason }, { backoffMs, retryJitterRatio, retryJitterMode }) {
197
- const rawWait = nextDelayMs ?? backoffMs[Math.min(attempt, backoffMs.length - 1)] ?? 0;
198
- return nextDelayReason === 'retry-after'
199
- ? Math.max(0, rawWait)
200
- : jitterDelayMs(rawWait, retryJitterRatio, retryJitterMode);
286
+ function retryWaitMs(
287
+ attempt,
288
+ { nextDelayMs, nextDelayReason },
289
+ { backoffMs, retryJitterRatio, retryJitterMode, retryAfterJitterRatio = 0, adviceAtLeastBackoff = false }
290
+ ) {
291
+ const step = backoffMs[Math.min(attempt, backoffMs.length - 1)] ?? 0;
292
+ if (nextDelayReason !== 'retry-after') return jitterDelayMs(nextDelayMs ?? step, retryJitterRatio, retryJitterMode);
293
+ let wait = Math.max(0, nextDelayMs ?? 0);
294
+ if (adviceAtLeastBackoff) wait = Math.max(wait, jitterDelayMs(step, retryJitterRatio, retryJitterMode));
295
+ // Positive-only jitter never shortens the advice.
296
+ return retryAfterJitterRatio > 0 ? jitterDelayMs(wait, retryAfterJitterRatio, 'positive') : wait;
201
297
  }
202
298
 
203
299
  /**
@@ -260,7 +356,14 @@ export async function withRetry(fn, opts = {}) {
260
356
  let lastErr = null;
261
357
  let nextDelayMs = null;
262
358
  let nextDelayReason = null;
263
- const state = { consecutive529Errors: Math.max(0, Number(opts.initialConsecutive529Errors) || 0) };
359
+ // `opts.overloadState` ({ consecutive529Errors }) lets one logical send share
360
+ // its overload count across every withRetry it runs (streaming, recovery
361
+ // cycles, non-streaming fallback), so the configured fallback still fires.
362
+ const shared = opts.overloadState && typeof opts.overloadState === 'object' ? opts.overloadState : null;
363
+ const state = shared || {};
364
+ if (!Number.isFinite(state.consecutive529Errors)) {
365
+ state.consecutive529Errors = Math.max(0, Number(opts.initialConsecutive529Errors) || 0);
366
+ }
264
367
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
265
368
  throwIfRetryAborted(signal);
266
369
  if (attempt > 0) {
@@ -11,6 +11,46 @@ import {
11
11
  isTransientErrorCode,
12
12
  typedStatusFrom,
13
13
  } from './retry-classification.mjs';
14
+ import { DETERMINISTIC_KINDS, providerErrorKind } from './provider-error-kind.mjs';
15
+
16
+ // A 429 that is an overload or an ordinary rate limit is request-local: it is
17
+ // re-issued under the bounded mid-stream budget (the server's advice, when it
18
+ // sent one, sets the wait). Quota / plan / billing refusals never are.
19
+ // The advice-honoring mid-stream wait lives with the backoff schedule.
20
+ export { midstreamRetryDelayMs } from './retry-backoff.mjs';
21
+
22
+ // Per-provider parity, as policy options (default false = retry under the
23
+ // bounded budget with ordinary backoff):
24
+ // overloadRetryRequiresAdvice — an overload (server_is_overloaded,
25
+ // overloaded_error, 529, statusless overload event) retries ONLY when the
26
+ // server gave advice (Retry-After / retryAfterMs / RetryInfo).
27
+ // generic429RetryRequiresAdvice — a rate limit without a typed
28
+ // rate_limit_exceeded / slow_down code retries ONLY on advice.
29
+ // A typed rate_limit_exceeded / slow_down always retries (advice, else the
30
+ // delay its message names, else local backoff). Returns false when the
31
+ // policy denies the retry, null when it has no say.
32
+ const TYPED_RATE_LIMIT_CODES = new Set(['rate_limit_exceeded', 'slow_down']);
33
+ export function adviceGatedRetry(err, policy = {}) {
34
+ const { kind, code, retryAfterMs } = providerErrorKind(err);
35
+ const advised = retryAfterMs != null;
36
+ if (kind === 'overloaded' && policy.overloadRetryRequiresAdvice === true && !advised) return false;
37
+ if (
38
+ kind === 'rate_limited' &&
39
+ !TYPED_RATE_LIMIT_CODES.has(code) &&
40
+ policy.generic429RetryRequiresAdvice === true &&
41
+ !advised
42
+ ) {
43
+ return false;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function retryable429(err, policy) {
49
+ if (policy.retry429 === false) return false;
50
+ if (adviceGatedRetry(err, policy) === false) return false;
51
+ const { kind } = providerErrorKind(err);
52
+ return kind === 'overloaded' || kind === 'rate_limited';
53
+ }
14
54
 
15
55
  // F) Retry-budget profiles as DATA. The numbers live ONLY here now.
16
56
  // ws.*Retries (5) — one Responses stream retry budget.
@@ -88,19 +128,24 @@ function isWebSocketConnectionLimit(err) {
88
128
  // bucket. Buckets need typed codes; the DEFAULT for an unrecognized (or
89
129
  // absent) code is the bounded retry, with fatal refusal codes staying
90
130
  // terminal.
91
- function classifyResponseFailed(failed, attemptIndex, policy) {
131
+ function classifyResponseFailed(failed, attemptIndex, policy, err = null) {
92
132
  const detail = failed?.response?.error || failed?.error || failed;
93
133
  const failedStatus = typedStatusFrom(detail, failed?.response, failed);
134
+ // Deterministic codes outrank a numeric 5xx on the same payload.
135
+ const keys = [detail?.code, detail?.type]
136
+ .map((field) => (typeof field === 'string' ? field.trim().toLowerCase() : ''))
137
+ .filter(Boolean);
138
+ const codeClassifier = keys.map((key) => RESPONSE_FAILED_CODE_CLASSIFIERS.get(key)).find(Boolean) || null;
139
+ if (!codeClassifier && keys.some((key) => WIRE_ERROR_FATAL_CODES.has(key))) return null;
140
+ const subject = { responseFailed: failed, headers: err?.headers, retryAfterMs: err?.retryAfterMs };
141
+ if (DETERMINISTIC_KINDS.has(providerErrorKind(subject).kind)) return null;
142
+ // Statusless overload / generic rate-limit events follow the advice gate.
143
+ if (adviceGatedRetry(subject, policy) === false) return null;
94
144
  if (failedStatus >= 500 && failedStatus < 600) {
95
145
  if (TERMINAL_EDGE_STATUSES.has(failedStatus)) return null;
96
146
  return _allowMidstream(`http_${failedStatus}`, attemptIndex, policy);
97
147
  }
98
- for (const field of [detail?.code, detail?.type]) {
99
- const key = typeof field === 'string' ? field.trim().toLowerCase() : '';
100
- const classifier = RESPONSE_FAILED_CODE_CLASSIFIERS.get(key);
101
- if (classifier) return _allowMidstream(classifier, attemptIndex, policy);
102
- if (key && WIRE_ERROR_FATAL_CODES.has(key)) return null;
103
- }
148
+ if (codeClassifier) return _allowMidstream(codeClassifier, attemptIndex, policy);
104
149
  // A typed non-transient 4xx on the failure payload is a deterministic
105
150
  // refusal even without a recognized code string.
106
151
  if (failedStatus >= 400 && failedStatus < 500 && !TRANSIENT_STATUSES.has(failedStatus)) return null;
@@ -146,12 +191,19 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
146
191
  // NOMINAL closes count too: a pooled socket the server retires between
147
192
  // turns closes with 1000/1001/1005 and must reconnect fresh — treating
148
193
  // that as terminal killed the turn instead of reissuing it.
149
- if (!state.sawResponseCreated && !NON_TERMINAL_STREAM_CLOSE_CODES.has(closeCode)) return null;
194
+ // An EXPLICIT wire failure (response.failed / error event) before
195
+ // response.created exposed nothing either: it gets the same bounded WS
196
+ // retry budget, and its typed code decides below.
197
+ const failed = err?.responseFailed || state.responseFailedPayload;
198
+ if (!state.sawResponseCreated && !NON_TERMINAL_STREAM_CLOSE_CODES.has(closeCode) && !failed) return null;
150
199
  if (state.userAbort) return null;
151
200
 
152
201
  if (!err) return null;
153
202
  const status = Number(err?.httpStatus || 0);
154
- if (status === 401 || status === 403 || status === 429) return null;
203
+ if (status === 401 || status === 403) return null;
204
+ if (DETERMINISTIC_KINDS.has(providerErrorKind(err).kind)) return null;
205
+ if (status === 429) return retryable429(err, policy) ? _allowMidstream('http_429', attemptIndex, policy) : null;
206
+ if (status && adviceGatedRetry(err, policy) === false) return null;
155
207
  if (status >= 500 && status < 600) {
156
208
  if (TERMINAL_EDGE_STATUSES.has(status)) return null;
157
209
  return _allowMidstream(`http_${status}`, attemptIndex, policy);
@@ -167,8 +219,7 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
167
219
  return _allowMidstream(`ws_${closeCode}`, attemptIndex, policy);
168
220
  }
169
221
 
170
- const failed = err?.responseFailed || state.responseFailedPayload;
171
- if (failed) return classifyResponseFailed(failed, attemptIndex, policy);
222
+ if (failed) return classifyResponseFailed(failed, attemptIndex, policy, err);
172
223
  return null;
173
224
  }
174
225
 
@@ -181,8 +232,8 @@ const RESPONSE_FAILED_CODE_CLASSIFIERS = new Map([
181
232
  ['auth_expired', 'response_failed_auth_expired'],
182
233
  ['previous_response_not_found', 'previous_response_not_found'],
183
234
  ['websocket_connection_limit_reached', 'websocket_connection_limit'],
184
- // A rate limit is retried after the server-advised wait (the reference
185
- // client's RateLimitExceeded); a usage limit stays a fatal code.
235
+ // A rate limit is retried after the server-advised wait; a usage limit
236
+ // stays a fatal code.
186
237
  ['rate_limit_exceeded', 'response_failed_rate_limited'],
187
238
  ]);
188
239
 
@@ -197,7 +248,10 @@ function _classifyMidstreamSse(err, state, attemptIndex, policy) {
197
248
  if (!err) return null;
198
249
  const status = Number(err?.httpStatus || err?.status || err?.response?.status || 0);
199
250
  if (status === 401 || status === 403) return null;
200
- if (status === 429) return 'http_429';
251
+ if (DETERMINISTIC_KINDS.has(providerErrorKind(err).kind)) return null;
252
+ if (status === 429) return retryable429(err, policy) ? 'http_429' : null;
253
+ // Opt-in: overloads retry only on server advice.
254
+ if (adviceGatedRetry(err, policy) === false) return null;
201
255
  if (status >= 500 && status < 600) {
202
256
  if (TERMINAL_EDGE_STATUSES.has(status)) return null;
203
257
  return `http_${status}`;
@@ -322,13 +376,24 @@ export function shouldFallbackTransport(err, { signal, enabled = true } = {}) {
322
376
  // returns null. HTTP 401 is reserved for auth recovery and 426 for immediate
323
377
  // HTTPS fallback. The OpenAI OAuth caller opts out of 429 retries
324
378
  // (retry429:false); all other callers retain the historical retryable
325
- // UnexpectedStatus policy.
326
- export function classifyHandshakeError(err, { retry429 = true } = {}) {
379
+ // UnexpectedStatus policy. overloadRetryRequiresAdvice /
380
+ // generic429RetryRequiresAdvice (default false) opt a caller into
381
+ // advice-gated overload / generic-429 retries.
382
+ export function classifyHandshakeError(
383
+ err,
384
+ { retry429 = true, overloadRetryRequiresAdvice = false, generic429RetryRequiresAdvice = false } = {}
385
+ ) {
327
386
  if (!err) return null;
328
387
  const code = err.code || '';
329
388
  const status = Number(err.httpStatus || 0);
330
389
 
331
390
  if (status === 401 || status === 426 || (status === 429 && !retry429)) return null;
391
+ // Every deterministic refusal (quota, billing, auth, permission, policy,
392
+ // invalid request, context/size) is final, whatever 5xx or advice it carries.
393
+ if (DETERMINISTIC_KINDS.has(providerErrorKind(err).kind)) return null;
394
+ if (status && adviceGatedRetry(err, { overloadRetryRequiresAdvice, generic429RetryRequiresAdvice }) === false) {
395
+ return null;
396
+ }
332
397
  if (status > 0) {
333
398
  // Typed transient handshake statuses only. A 403/404/4xx upgrade refusal
334
399
  // is a deterministic decision: spending the retry budget on it cannot
@@ -351,6 +351,9 @@ export function normalizeCompactionConfig(value = {}) {
351
351
  return {
352
352
  ...next,
353
353
  auto: raw.auto !== false && raw.enabled !== false,
354
+ // Compact a conversation on its current model before switching it to
355
+ // another one in place (default on).
356
+ onModelChange: raw.onModelChange !== false,
354
357
  };
355
358
  }
356
359
 
@@ -80,21 +80,3 @@ export function acknowledgePendingGoalReminder(session, revision) {
80
80
  session.updatedAt = Date.now();
81
81
  return true;
82
82
  }
83
-
84
- export function prependGoalReminderToLatestUserMessage(messages, content) {
85
- const reminder = clean(content);
86
- const out = Array.isArray(messages) ? [...messages] : [];
87
- if (!reminder) return out;
88
- for (let index = out.length - 1; index >= 0; index -= 1) {
89
- const message = out[index];
90
- if (message?.role !== 'user') continue;
91
- const current = message.content;
92
- const nextContent = Array.isArray(current)
93
- ? [{ type: 'text', text: `${reminder}\n\n` }, ...current]
94
- : `${reminder}\n\n${String(current ?? '')}`;
95
- out[index] = { ...message, content: nextContent };
96
- return out;
97
- }
98
- out.push({ role: 'user', content: reminder });
99
- return out;
100
- }
@@ -7,6 +7,7 @@ import { modelSupportsServiceTier } from '../providers/model-service-tiers.mjs';
7
7
  import { openAiDirectSupportsFast, openAiDirectSupportsUltrafast } from '../providers/openai-direct-request.mjs';
8
8
  import { supportsAnthropicFastMode } from '../../../shared/llm/anthropic-betas.mjs';
9
9
  import { normalizeContextTokens } from '../../../shared/llm/default-context-window.mjs';
10
+ import { getModelMetadataSync } from '../../../shared/llm/model-catalog.mjs';
10
11
 
11
12
  const FAST_CAPABLE_PROVIDERS = new Set([
12
13
  'anthropic',
@@ -24,41 +25,59 @@ export function routeFastKey(provider, model) {
24
25
  return p && m ? `${p}/${m}` : '';
25
26
  }
26
27
 
27
- function openAiModelSupportsHostedWebSearch(model) {
28
- const id = clean(model?.id || model).toLowerCase();
29
- if (!id) return false;
30
- if (model?.supportsWebSearch === true) return true;
28
+ // What one catalog row states about hosted web search: its own
29
+ // `supportsWebSearch` flag (true or false), else a web_search tool in its tool
30
+ // lists. Null when the row says nothing, which leaves the answer to the id
31
+ // fallback of each family.
32
+ function rowWebSearchDeclaration(row) {
33
+ if (!row || typeof row !== 'object') return null;
34
+ if (typeof row.supportsWebSearch === 'boolean') return row.supportsWebSearch;
31
35
  const tools = [
32
- ...(Array.isArray(model?.supportedTools) ? model.supportedTools : []),
33
- ...(Array.isArray(model?.tools) ? model.tools : []),
34
- ...(Array.isArray(model?.capabilities?.tools) ? model.capabilities.tools : []),
36
+ ...(Array.isArray(row.supportedTools) ? row.supportedTools : []),
37
+ ...(Array.isArray(row.tools) ? row.tools : []),
38
+ ...(Array.isArray(row.capabilities?.tools) ? row.capabilities.tools : []),
35
39
  ].map((tool) => clean(tool?.type || tool?.name || tool).toLowerCase());
36
- if (tools.some((tool) => tool === 'web_search' || tool === 'web_search_preview')) return true;
37
- if (/codex|image|audio|tts|stt|embedding|rerank|moderation|search-preview/.test(id)) return false;
38
- return /^gpt-(5(?:\.|$|-)|4\.1(?:-|$)|4o(?:-|$)|4\.5(?:-|$))/.test(id) || /^o[34](?:-|$)/.test(id);
39
- }
40
-
41
- function grokModelSupportsHostedWebSearch(model) {
42
- const id = clean(model?.id || model).toLowerCase();
43
- if (!id || /imagine|image|video|composer/.test(id)) return false;
44
- if (id === 'grok-build') return false;
45
- return /^grok-/.test(id);
40
+ return tools.some((tool) => tool === 'web_search' || tool === 'web_search_preview') ? true : null;
46
41
  }
47
42
 
48
- function geminiModelSupportsHostedWebSearch(model) {
49
- const id = clean(model?.id || model).toLowerCase();
50
- if (!id || /embedding|aqa|imagen|veo|tts|image|computer-use|customtools/.test(id)) return false;
51
- return /^gemini-(3(?:\.|-|$)|2\.5-|2\.0-flash)/.test(id);
43
+ // The route's own row first, then the provider catalog's row for the model.
44
+ function hostedWebSearchDeclared(model, catalogRow) {
45
+ return rowWebSearchDeclaration(model) ?? rowWebSearchDeclaration(catalogRow);
52
46
  }
53
47
 
54
- function anthropicModelSupportsHostedWebSearch(model) {
48
+ // When neither the route row nor the catalog states hosted web search, the
49
+ // vendor family (already fixed by the provider) is taken as capable unless the
50
+ // id names a non-chat modality or a retired generation. Only those closed,
51
+ // frozen sets are matched by id, so a model released later needs no entry here;
52
+ // guessing "capable" wrongly would hard-fail the request, which is why the
53
+ // retired generations stay excluded.
54
+ function hostedWebSearchFallback(model, catalogRow, unsupportedId) {
55
55
  const id = clean(model?.id || model).toLowerCase();
56
56
  if (!id) return false;
57
- const match = id.match(/^claude-(opus|sonnet|haiku|fable|mythos)-(\d+)(?:[-.](\d+))?/);
58
- if (!match) return false;
59
- return (Number(match[2]) || 0) >= 4;
57
+ const declared = hostedWebSearchDeclared(model, catalogRow);
58
+ if (declared !== null) return declared;
59
+ return !unsupportedId.test(id);
60
60
  }
61
61
 
62
+ const NON_CHAT_ID = 'embedding|rerank|moderation|image|imagen|imagine|video|veo|audio|tts|stt|aqa|search-preview';
63
+
64
+ const OPENAI_UNSUPPORTED = new RegExp(
65
+ `${NON_CHAT_ID}|codex|^gpt-(?:3|4(?:$|-))|^o[12](?:-|$)|^(?:babbage|davinci|text-)`
66
+ );
67
+ const GROK_UNSUPPORTED = new RegExp(`${NON_CHAT_ID}|composer|^grok-build$|^grok-[123](?:[.-]|$)`);
68
+ // The Gemini family is also reached through a relay serving other vendors'
69
+ // models, whose hosted search is Gemini's alone, so a non-Gemini id is out.
70
+ const GEMINI_UNSUPPORTED = new RegExp(
71
+ `${NON_CHAT_ID}|^(?!gemini-)|computer-use|customtools|^gemini-(?:1[.-]|pro|ultra)|^gemini-2\\.0-(?!flash)`
72
+ );
73
+ const ANTHROPIC_UNSUPPORTED = /^claude-(?:\d|instant)|^claude-[a-z]+-[123](?:[-.]|$)/;
74
+
75
+ const openAiModelSupportsHostedWebSearch = (model, row) => hostedWebSearchFallback(model, row, OPENAI_UNSUPPORTED);
76
+ const grokModelSupportsHostedWebSearch = (model, row) => hostedWebSearchFallback(model, row, GROK_UNSUPPORTED);
77
+ const geminiModelSupportsHostedWebSearch = (model, row) => hostedWebSearchFallback(model, row, GEMINI_UNSUPPORTED);
78
+ const anthropicModelSupportsHostedWebSearch = (model, row) =>
79
+ hostedWebSearchFallback(model, row, ANTHROPIC_UNSUPPORTED);
80
+
62
81
  export function fastCapableFor(provider, model, effort = null, modelParameters = {}) {
63
82
  const p = clean(provider);
64
83
  if (!FAST_CAPABLE_PROVIDERS.has(p)) return false;
@@ -77,8 +96,13 @@ export function fastCapableFor(provider, model, effort = null, modelParameters =
77
96
  }
78
97
  return !selectedEffort || fastEfforts.length === 0 || fastEfforts.includes(selectedEffort);
79
98
  }
80
- if (p === 'openai') return openAiDirectSupportsFast(model);
81
99
  if (p === 'openai-oauth') return modelSupportsServiceTier(model, 'priority');
100
+ // The route row's own declaration first, then the catalog via the helpers.
101
+ if (typeof model?.supportsFastMode === 'boolean') return model.supportsFastMode;
102
+ const modelId = clean(model?.id || model);
103
+ const declaredFast = modelId ? getModelMetadataSync(modelId, p)?.supportsFastMode : undefined;
104
+ if (typeof declaredFast === 'boolean') return declaredFast;
105
+ if (p === 'openai') return openAiDirectSupportsFast(model);
82
106
  if (p === 'anthropic' || p === 'anthropic-oauth') return supportsAnthropicFastMode(clean(model?.id || model));
83
107
  return false;
84
108
  }
@@ -100,10 +124,15 @@ const HOSTED_WEB_SEARCH_BY_FAMILY = {
100
124
  // webSearchCapableFor needs the web-search family mapping, which lives in
101
125
  // workflow.mjs and is pure. Wire it in via a factory to keep this module free
102
126
  // of a circular import at load time.
103
- export function makeWebSearchCapableFor(webSearchProviderFamily) {
127
+ // `catalogRowFor(provider, id)` is the provider catalog's row for the model;
128
+ // callers that hold only a bare id (or a row the provider list left silent)
129
+ // still get the catalog's answer before the id fallback applies.
130
+ export function makeWebSearchCapableFor(webSearchProviderFamily, catalogRowFor = getModelMetadataSync) {
104
131
  return function webSearchCapableFor(provider, model) {
105
132
  const supports = HOSTED_WEB_SEARCH_BY_FAMILY[webSearchProviderFamily(provider)];
106
- return supports ? supports(model) : false;
133
+ if (!supports) return false;
134
+ const id = clean(model?.id || model);
135
+ return supports(model, id ? catalogRowFor(id, provider) : null);
107
136
  };
108
137
  }
109
138