@vellumai/assistant 0.11.6 → 0.11.7-staging.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 (478) hide show
  1. package/ARCHITECTURE.md +31 -19
  2. package/Dockerfile +1 -0
  3. package/docs/architecture/security.md +3 -3
  4. package/docs/flux-turn-detection-spike.md +8 -7
  5. package/docs/runbook-trusted-contacts.md +14 -0
  6. package/docs/stt-provider-onboarding.md +8 -4
  7. package/knip.json +1 -0
  8. package/node_modules/@vellumai/avatar-manifest/package.json +18 -0
  9. package/node_modules/@vellumai/avatar-manifest/src/__tests__/manifest.test.ts +173 -0
  10. package/node_modules/@vellumai/avatar-manifest/src/__tests__/read.test.ts +114 -0
  11. package/node_modules/@vellumai/avatar-manifest/src/index.ts +29 -0
  12. package/node_modules/@vellumai/avatar-manifest/src/layout.ts +18 -0
  13. package/node_modules/@vellumai/avatar-manifest/src/manifest.ts +156 -0
  14. package/node_modules/@vellumai/avatar-manifest/src/read.ts +109 -0
  15. package/node_modules/@vellumai/avatar-manifest/tsconfig.json +20 -0
  16. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +37 -0
  17. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/remote-web-pairing.test.ts +389 -0
  18. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +22 -0
  19. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +508 -18
  20. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
  21. package/node_modules/@vellumai/ces-client/src/http-credentials.ts +161 -0
  22. package/node_modules/@vellumai/ces-client/src/index.ts +1 -0
  23. package/node_modules/@vellumai/environments/package.json +2 -1
  24. package/node_modules/@vellumai/environments/src/shell.test.ts +56 -0
  25. package/node_modules/@vellumai/environments/src/shell.ts +65 -0
  26. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +37 -0
  27. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/remote-web-pairing.test.ts +389 -0
  28. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +22 -0
  29. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +508 -18
  30. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
  31. package/node_modules/@vellumai/gateway-client/src/__tests__/contact-read-contracts.test.ts +32 -0
  32. package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -0
  33. package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +82 -0
  34. package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +56 -0
  35. package/node_modules/@vellumai/gateway-client/src/__tests__/trust-verdict-contract.test.ts +18 -0
  36. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +5 -11
  37. package/node_modules/@vellumai/gateway-client/src/channel-permission-contract.ts +3 -1
  38. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +9 -0
  39. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +4 -0
  40. package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +75 -0
  41. package/node_modules/@vellumai/gateway-client/src/index.ts +17 -0
  42. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +8 -0
  43. package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +47 -0
  44. package/node_modules/@vellumai/gateway-client/src/trust-verdict-contract.ts +11 -0
  45. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +37 -0
  46. package/node_modules/@vellumai/service-contracts/src/__tests__/remote-web-pairing.test.ts +389 -0
  47. package/node_modules/@vellumai/service-contracts/src/ingress.ts +22 -0
  48. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +508 -18
  49. package/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
  50. package/openapi.yaml +419 -55
  51. package/package.json +4 -1
  52. package/scripts/sync-llm-catalog.ts +6 -0
  53. package/src/__tests__/approval-card-edit-inbound-handoff.test.ts +199 -0
  54. package/src/__tests__/approval-card-edit.test.ts +153 -0
  55. package/src/__tests__/attachments-store-heic-normalize.test.ts +1 -1
  56. package/src/__tests__/available-skills.test.ts +54 -0
  57. package/src/__tests__/avatar-identity-sync.test.ts +5 -1
  58. package/src/__tests__/catalog-install-normalize.test.ts +9 -1
  59. package/src/__tests__/channel-policy.test.ts +1 -3
  60. package/src/__tests__/channel-readiness-discord.test.ts +9 -70
  61. package/src/__tests__/compactor-call-site-logging.test.ts +61 -0
  62. package/src/__tests__/config-loader-backfill.test.ts +69 -0
  63. package/src/__tests__/config-schema.test.ts +2 -3
  64. package/src/__tests__/conversation-agent-loop.test.ts +238 -0
  65. package/src/__tests__/conversation-attachments.test.ts +0 -1
  66. package/src/__tests__/conversation-error.test.ts +44 -0
  67. package/src/__tests__/conversation-evictor.test.ts +19 -0
  68. package/src/__tests__/conversation-fork-referential.test.ts +73 -63
  69. package/src/__tests__/conversation-fork-retrospective.test.ts +99 -74
  70. package/src/__tests__/conversation-runtime-assembly.test.ts +23 -0
  71. package/src/__tests__/conversation-slash.test.ts +9 -0
  72. package/src/__tests__/conversation-title-service.test.ts +97 -0
  73. package/src/__tests__/conversation-tool-setup-attribution.test.ts +16 -0
  74. package/src/__tests__/credential-broker-server-use.test.ts +0 -218
  75. package/src/__tests__/credential-broker.test.ts +43 -420
  76. package/src/__tests__/credential-record-write-through.test.ts +78 -0
  77. package/src/__tests__/credential-routes.test.ts +17 -1
  78. package/src/__tests__/credential-security-invariants.test.ts +8 -9
  79. package/src/__tests__/default-profile-catalog-fallback.test.ts +289 -0
  80. package/src/__tests__/delete-propagation.test.ts +92 -2
  81. package/src/__tests__/discord-callback-round-trip.test.ts +93 -0
  82. package/src/__tests__/discord-channel-config.test.ts +237 -0
  83. package/src/__tests__/edit-propagation.test.ts +42 -2
  84. package/src/__tests__/evict-conversations-for-reload.test.ts +51 -1
  85. package/src/__tests__/fallback-breaker.test.ts +1335 -0
  86. package/src/__tests__/gateway-threshold-reader-mock.ts +6 -0
  87. package/src/__tests__/guardian-card-withdrawal.test.ts +30 -0
  88. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +0 -2
  89. package/src/__tests__/image-conversion.test.ts +38 -4
  90. package/src/__tests__/install-skill-routing.test.ts +25 -0
  91. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  92. package/src/__tests__/llm-resolver.test.ts +74 -0
  93. package/src/__tests__/llm-schema-fallback-profile.test.ts +359 -0
  94. package/src/__tests__/managed-fallback-dispatch.test.ts +430 -0
  95. package/src/__tests__/managed-profile-guard.test.ts +28 -0
  96. package/src/__tests__/managed-speech-defaults.test.ts +196 -1
  97. package/src/__tests__/mcp-cli.test.ts +23 -1
  98. package/src/__tests__/mcp-tool-annotations-risk.test.ts +43 -34
  99. package/src/__tests__/media-stream-stt-session.test.ts +2 -0
  100. package/src/__tests__/notification-decision-fallback.test.ts +106 -0
  101. package/src/__tests__/notification-discord-adapter.test.ts +162 -0
  102. package/src/__tests__/oauth-connect-orchestrator.test.ts +95 -0
  103. package/src/__tests__/persist-unsendable-image-downscale.test.ts +6 -8
  104. package/src/__tests__/persist-unsendable-image.test.ts +3 -3
  105. package/src/__tests__/platform.test.ts +19 -1
  106. package/src/__tests__/plugin-import-boundary-guard.test.ts +5 -1
  107. package/src/__tests__/provider-catalog-visibility.test.ts +17 -0
  108. package/src/__tests__/provider-platform-proxy-integration.test.ts +8 -1
  109. package/src/__tests__/provider-usage-tracking.test.ts +56 -0
  110. package/src/__tests__/require-fresh-approval.test.ts +5 -2
  111. package/src/__tests__/retry-fallback-escalation.test.ts +1093 -0
  112. package/src/__tests__/schedule-retry.test.ts +183 -4
  113. package/src/__tests__/scheduler-reuse-conversation.test.ts +6 -6
  114. package/src/__tests__/secret-routes-platform-proxy.test.ts +55 -0
  115. package/src/__tests__/skill-load-tool.test.ts +34 -0
  116. package/src/__tests__/skill-tool-factory.test.ts +6 -1
  117. package/src/__tests__/skill-tool-manifest.test.ts +20 -0
  118. package/src/__tests__/skills.test.ts +25 -0
  119. package/src/__tests__/subagent-manager-notify.test.ts +38 -0
  120. package/src/__tests__/terminal-tools.test.ts +2 -0
  121. package/src/__tests__/tool-approval-handler.test.ts +146 -2
  122. package/src/__tests__/tool-approval-seed-content-blocks.test.ts +108 -2
  123. package/src/__tests__/tool-audit.test.ts +111 -40
  124. package/src/__tests__/tool-executor-lifecycle-events.test.ts +228 -18
  125. package/src/__tests__/ui-shape-teaching.test.ts +1 -1
  126. package/src/__tests__/verification-control-plane-policy.test.ts +6 -2
  127. package/src/__tests__/voice-config-update.test.ts +43 -0
  128. package/src/__tests__/workspace-migration-147-rename-colliding-backup-profile-names.test.ts +480 -0
  129. package/src/__tests__/workspace-migration-148-strip-unsupported-fallback-profiles.test.ts +167 -0
  130. package/src/__tests__/workspace-migration-149-repoint-backup-profile-selections.test.ts +144 -0
  131. package/src/agent/loop.ts +31 -2
  132. package/src/approvals/guardian-card-withdrawal.ts +22 -0
  133. package/src/avatar/__tests__/ensure-raster.test.ts +291 -0
  134. package/src/avatar/avatar-manifest.ts +48 -100
  135. package/src/avatar/avatar-store.ts +7 -7
  136. package/src/avatar/ensure-raster.ts +146 -0
  137. package/src/avatar/resvg-lazy.test.ts +0 -1
  138. package/src/avatar/traits-png-sync.ts +9 -10
  139. package/src/background-wake/wake-intent-hooks.test.ts +0 -1
  140. package/src/backup/__tests__/paths.test.ts +6 -2
  141. package/src/backup/paths.ts +6 -4
  142. package/src/bundler/app-compiler.ts +2 -2
  143. package/src/bundler/compiler-tools.test.ts +36 -0
  144. package/src/bundler/compiler-tools.ts +54 -18
  145. package/src/calls/__tests__/telephony-synthesis-language.test.ts +81 -0
  146. package/src/calls/__tests__/voice-session-bridge.test.ts +57 -0
  147. package/src/calls/media-stream-stt-session.ts +2 -1
  148. package/src/calls/telephony-synthesis-language.ts +13 -5
  149. package/src/calls/voice-session-bridge.ts +19 -4
  150. package/src/channels/config.ts +23 -12
  151. package/src/cli/AGENTS.md +3 -1
  152. package/src/cli/commands/backup.help.ts +10 -13
  153. package/src/cli/commands/contacts.help.ts +16 -4
  154. package/src/cli/commands/contacts.ts +5 -1
  155. package/src/cli/commands/credentials.help.ts +1 -1
  156. package/src/cli/commands/inference-providers.ts +18 -53
  157. package/src/cli/commands/mcp.help.ts +6 -5
  158. package/src/cli/commands/mcp.ts +1 -1
  159. package/src/cli/commands/memory/memory-retrospective.ts +1 -0
  160. package/src/cli/commands/stt.help.ts +2 -2
  161. package/src/config/__tests__/backup-schema.test.ts +1 -1
  162. package/src/config/__tests__/default-profile-catalog.test.ts +83 -2
  163. package/src/config/__tests__/loader-sparse-array-cleanup.test.ts +1 -1
  164. package/src/config/__tests__/memory-retrospective-schema.test.ts +12 -15
  165. package/src/config/bundled-skills/computer-use/SKILL.md +6 -6
  166. package/src/config/bundled-skills/computer-use/TOOLS.json +9 -6
  167. package/src/config/bundled-skills/media-processing/services/audio-transcribe.ts +3 -1
  168. package/src/config/bundled-skills/media-processing/services/preprocess.ts +1 -1
  169. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
  170. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +16 -15
  171. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +2 -1
  172. package/src/config/bundled-skills/settings/TOOLS.json +11 -5
  173. package/src/config/bundled-skills/settings/tools/open-system-settings.test.ts +85 -0
  174. package/src/config/bundled-skills/settings/tools/open-system-settings.ts +33 -8
  175. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +54 -3
  176. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +1 -1
  177. package/src/config/default-profile-catalog.ts +195 -10
  178. package/src/config/default-profile-names.ts +41 -0
  179. package/src/config/feature-flag-registry.json +34 -2
  180. package/src/config/inference-profile-validation.ts +11 -2
  181. package/src/config/llm-resolver.ts +25 -0
  182. package/src/config/loader.ts +3 -0
  183. package/src/config/managed-speech-defaults.ts +160 -10
  184. package/src/config/schema.ts +2 -0
  185. package/src/config/schemas/__tests__/live-voice.test.ts +7 -6
  186. package/src/config/schemas/__tests__/stt.test.ts +94 -0
  187. package/src/config/schemas/backup.ts +1 -1
  188. package/src/config/schemas/channels.ts +32 -0
  189. package/src/config/schemas/live-voice.ts +13 -4
  190. package/src/config/schemas/llm.ts +263 -15
  191. package/src/config/schemas/mcp.ts +10 -2
  192. package/src/config/schemas/memory-retrospective.ts +1 -19
  193. package/src/config/schemas/services.ts +24 -0
  194. package/src/config/schemas/stt.ts +128 -5
  195. package/src/config/seed-inference-profiles.ts +32 -3
  196. package/src/config/skill-state.ts +5 -0
  197. package/src/config/skills.ts +21 -0
  198. package/src/contacts/notify-contacts-changed.ts +5 -2
  199. package/src/context/compactor.ts +74 -1
  200. package/src/daemon/__tests__/conversation-tool-setup.test.ts +138 -1
  201. package/src/daemon/config-watcher.ts +2 -1
  202. package/src/daemon/conversation-agent-loop-handlers.ts +27 -0
  203. package/src/daemon/conversation-agent-loop.ts +86 -10
  204. package/src/daemon/conversation-error.ts +25 -0
  205. package/src/daemon/conversation-evictor.ts +2 -4
  206. package/src/daemon/conversation-runtime-assembly.ts +19 -7
  207. package/src/daemon/conversation-skill-tools.ts +5 -1
  208. package/src/daemon/conversation-slash.ts +2 -2
  209. package/src/daemon/conversation-store.ts +8 -1
  210. package/src/daemon/conversation-tool-setup.ts +37 -0
  211. package/src/daemon/conversation.ts +0 -4
  212. package/src/daemon/handlers/channel-config-result.ts +30 -0
  213. package/src/daemon/handlers/config-discord-channel.ts +301 -0
  214. package/src/daemon/handlers/config-ingress.ts +9 -0
  215. package/src/daemon/handlers/config-slack-channel.ts +13 -15
  216. package/src/daemon/handlers/config-telegram.ts +2 -5
  217. package/src/daemon/handlers/conversations.ts +0 -2
  218. package/src/daemon/handlers/skills.ts +25 -7
  219. package/src/daemon/lifecycle.ts +3 -1
  220. package/src/daemon/message-types/messages.ts +0 -2
  221. package/src/daemon/tool-setup-types.ts +5 -3
  222. package/src/daemon/trust-context-types.test.ts +33 -0
  223. package/src/heartbeat/__tests__/heartbeat-service.test.ts +0 -1
  224. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +58 -4
  225. package/src/live-voice/__tests__/live-voice-managed-speech-fallback.test.ts +8 -3
  226. package/src/live-voice/__tests__/live-voice-session-manager.test.ts +73 -11
  227. package/src/live-voice/__tests__/live-voice-session-preflight.test.ts +148 -2
  228. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +64 -0
  229. package/src/live-voice/__tests__/live-voice-stt.test.ts +3 -0
  230. package/src/live-voice/__tests__/live-voice-text-turn.test.ts +391 -0
  231. package/src/live-voice/__tests__/protocol.test.ts +224 -0
  232. package/src/live-voice/live-voice-credential-preflight.ts +5 -1
  233. package/src/live-voice/live-voice-session-manager.ts +39 -2
  234. package/src/live-voice/live-voice-session.ts +224 -9
  235. package/src/live-voice/protocol.ts +187 -1
  236. package/src/messaging/providers/discord/api.ts +2 -1
  237. package/src/messaging/providers/discord/withdraw.ts +62 -0
  238. package/src/messaging/read-provider-metadata.ts +32 -0
  239. package/src/monitoring/__tests__/file-descriptors.test.ts +32 -0
  240. package/src/monitoring/__tests__/process-memory.test.ts +34 -1
  241. package/src/monitoring/db-integrity-sample.ts +1 -0
  242. package/src/monitoring/file-descriptors.ts +39 -6
  243. package/src/monitoring/process-memory.ts +37 -5
  244. package/src/monitoring/resource-sampler.ts +25 -8
  245. package/src/notifications/__tests__/activity-failed-dedupe.test.ts +161 -0
  246. package/src/notifications/__tests__/background-failure-signal.test.ts +107 -0
  247. package/src/notifications/__tests__/connected-channels.test.ts +12 -0
  248. package/src/notifications/__tests__/copy-composer.test.ts +83 -0
  249. package/src/notifications/__tests__/destination-resolver.test.ts +38 -0
  250. package/src/notifications/activity-failed-dedupe.ts +95 -0
  251. package/src/notifications/adapters/discord.ts +132 -0
  252. package/src/notifications/adapters/shared.ts +16 -0
  253. package/src/notifications/adapters/slack.ts +6 -1
  254. package/src/notifications/adapters/telegram.ts +5 -8
  255. package/src/notifications/approval-card-data.ts +94 -1
  256. package/src/notifications/background-failure-signal.ts +116 -0
  257. package/src/notifications/broadcaster.ts +3 -9
  258. package/src/notifications/copy-composer.ts +41 -8
  259. package/src/notifications/decision-engine.ts +55 -0
  260. package/src/notifications/destination-resolver.ts +45 -5
  261. package/src/notifications/emit-signal.ts +19 -5
  262. package/src/notifications/guardian-question-mode.ts +57 -0
  263. package/src/notifications/types.ts +2 -8
  264. package/src/oauth/__tests__/seed-providers-managed.test.ts +18 -0
  265. package/src/oauth/connect-orchestrator.ts +16 -0
  266. package/src/oauth/seed-providers.ts +262 -0
  267. package/src/permissions/checker.test.ts +21 -0
  268. package/src/permissions/checker.ts +6 -0
  269. package/src/permissions/gateway-threshold-reader.test.ts +184 -0
  270. package/src/permissions/gateway-threshold-reader.ts +144 -10
  271. package/src/permissions/prompter.ts +0 -6
  272. package/src/permissions/types.ts +5 -4
  273. package/src/persistence/__tests__/bulk-write-gate.test.ts +0 -38
  274. package/src/persistence/bulk-write-gate.ts +9 -10
  275. package/src/persistence/conversation-crud.ts +88 -134
  276. package/src/persistence/conversation-title-service.ts +43 -29
  277. package/src/persistence/embeddings/embedding-local.ts +14 -85
  278. package/src/persistence/embeddings/embedding-runtime-manager.ts +1 -0
  279. package/src/persistence/lifecycle-events-store.test.ts +50 -0
  280. package/src/persistence/lifecycle-events-store.ts +75 -5
  281. package/src/platform/platform-patch-queue.test.ts +368 -0
  282. package/src/platform/platform-patch-queue.ts +190 -0
  283. package/src/platform/sync-avatar.test.ts +527 -0
  284. package/src/platform/sync-avatar.ts +209 -0
  285. package/src/platform/sync-identity.test.ts +75 -0
  286. package/src/platform/sync-identity.ts +17 -110
  287. package/src/plugin-api/model-profiles.test.ts +2 -2
  288. package/src/plugin-api/model-profiles.ts +7 -7
  289. package/src/plugin-api/transcription-session.ts +1 -1
  290. package/src/plugins/defaults/compaction/window-manager.ts +4 -0
  291. package/src/plugins/defaults/image-recovery/recover.ts +2 -2
  292. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +133 -34
  293. package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +21 -31
  294. package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +13 -9
  295. package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +31 -4
  296. package/src/plugins/defaults/memory/memory-retrospective-constants.ts +0 -11
  297. package/src/plugins/defaults/memory/memory-retrospective-fork-boundary.ts +27 -13
  298. package/src/plugins/defaults/memory/memory-retrospective-job.ts +183 -113
  299. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +13 -13
  300. package/src/plugins/defaults/memory/src/memory-v2-routes.ts +2 -2
  301. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +1 -1
  302. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +4 -2
  303. package/src/plugins/defaults/memory/substrate/sweep-job.ts +9 -42
  304. package/src/plugins/defaults/memory/v2/rerank-local.ts +1 -0
  305. package/src/plugins/defaults/memory/worker.ts +7 -0
  306. package/src/providers/__tests__/provider-secret-catalog.test.ts +1 -0
  307. package/src/providers/__tests__/retry-callsite.test.ts +55 -0
  308. package/src/providers/__tests__/vellum-connection-routing.test.ts +21 -3
  309. package/src/providers/connection-resolution.ts +2 -17
  310. package/src/providers/fallback-breaker.ts +389 -0
  311. package/src/providers/inference/__tests__/adapter-factory-opencode.test.ts +92 -0
  312. package/src/providers/inference/__tests__/base-url-route-validation.test.ts +16 -0
  313. package/src/providers/inference/__tests__/base-url-security.test.ts +12 -0
  314. package/src/providers/inference/adapter-factory.ts +246 -3
  315. package/src/providers/inference/auth.ts +15 -14
  316. package/src/providers/inference/chatgpt-subscription-auth.ts +70 -0
  317. package/src/providers/inference/chatgpt-subscription-credentials.ts +84 -0
  318. package/src/providers/inference/resolve-auth.ts +1 -1
  319. package/src/providers/media-resolve.ts +2 -3
  320. package/src/providers/model-catalog.ts +100 -0
  321. package/src/providers/openai/__tests__/api-error-normalization.test.ts +22 -0
  322. package/src/providers/openai/__tests__/chat-template-flatten.test.ts +198 -0
  323. package/src/providers/openai/api-error-normalization.ts +13 -0
  324. package/src/providers/openai/chat-completions-provider.ts +84 -0
  325. package/src/providers/opencode/client.test.ts +150 -0
  326. package/src/providers/opencode/client.ts +61 -0
  327. package/src/providers/platform-proxy/constants.ts +5 -0
  328. package/src/providers/provider-resolvability.ts +20 -0
  329. package/src/providers/provider-secret-catalog.ts +3 -2
  330. package/src/providers/retry.ts +956 -68
  331. package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +42 -0
  332. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +136 -0
  333. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +120 -0
  334. package/src/providers/speech-to-text/__tests__/resolve.test.ts +316 -12
  335. package/src/providers/speech-to-text/deepgram-flux-frames.ts +83 -3
  336. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +132 -4
  337. package/src/providers/speech-to-text/provider-catalog.ts +191 -3
  338. package/src/providers/speech-to-text/resolve.ts +132 -24
  339. package/src/providers/speech-to-text/vellum-managed-flux-realtime.ts +296 -0
  340. package/src/providers/types.ts +39 -0
  341. package/src/providers/usage-tracking.ts +12 -1
  342. package/src/providers/vellum/client.ts +29 -0
  343. package/src/providers/vellum-model-routing.test.ts +2 -0
  344. package/src/providers/vellum-model-routing.ts +3 -3
  345. package/src/runtime/AGENTS.md +1 -2
  346. package/src/runtime/__tests__/agent-wake.test.ts +105 -3
  347. package/src/runtime/__tests__/background-job-runner.test.ts +89 -3
  348. package/src/runtime/agent-wake.ts +44 -5
  349. package/src/runtime/background-job-runner.ts +68 -50
  350. package/src/runtime/channel-readiness-service.ts +0 -39
  351. package/src/runtime/http-server.ts +12 -4
  352. package/src/runtime/routes/__tests__/avatar-state-routes.test.ts +18 -18
  353. package/src/runtime/routes/__tests__/backup-routes.test.ts +9 -5
  354. package/src/runtime/routes/__tests__/chatgpt-subscription-auth-routes.test.ts +372 -0
  355. package/src/runtime/routes/__tests__/contact-routes.test.ts +7 -0
  356. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +96 -0
  357. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +37 -0
  358. package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +2 -1
  359. package/src/runtime/routes/__tests__/mcp-add-default-risk.test.ts +87 -0
  360. package/src/runtime/routes/acp-claude-auth-routes.ts +25 -54
  361. package/src/runtime/routes/approval-routes.ts +0 -12
  362. package/src/runtime/routes/avatar-routes.ts +17 -30
  363. package/src/runtime/routes/backup-routes.ts +2 -2
  364. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +296 -113
  365. package/src/runtime/routes/contact-prompt-routes.ts +65 -6
  366. package/src/runtime/routes/contact-routes.ts +20 -15
  367. package/src/runtime/routes/conversation-query-routes.ts +98 -10
  368. package/src/runtime/routes/conversation-routes.ts +2 -2
  369. package/src/runtime/routes/credential-routes.ts +2 -1
  370. package/src/runtime/routes/guardian-approval-interception.ts +12 -9
  371. package/src/runtime/routes/inbound-message-handler.ts +47 -48
  372. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +18 -13
  373. package/src/runtime/routes/inbound-stages/admission-policy.ts +2 -2
  374. package/src/runtime/routes/inbound-stages/edit-intercept.ts +20 -2
  375. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +9 -8
  376. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +1 -1
  377. package/src/runtime/routes/index.ts +2 -0
  378. package/src/runtime/routes/inference-profile-session-handler.ts +2 -2
  379. package/src/runtime/routes/inference-profiles-routes.ts +12 -7
  380. package/src/runtime/routes/inference-provider-connection-routes.ts +2 -2
  381. package/src/runtime/routes/inference-send-routes.ts +2 -2
  382. package/src/runtime/routes/ingress-status-routes.ts +2 -2
  383. package/src/runtime/routes/integrations/discord.ts +119 -0
  384. package/src/runtime/routes/llm-call-sites-routes.ts +2 -2
  385. package/src/runtime/routes/mcp-auth-routes.ts +12 -6
  386. package/src/runtime/routes/migration-routes.ts +3 -1
  387. package/src/runtime/routes/oauth-pending-flows.ts +136 -0
  388. package/src/runtime/routes/secret-routes.ts +7 -0
  389. package/src/runtime/routes/stt-routes.ts +38 -6
  390. package/src/runtime/routes/wake-conversation-routes.ts +8 -1
  391. package/src/runtime/routes/watch-routes.ts +9 -5
  392. package/src/runtime/sync/resource-sync-events.ts +2 -0
  393. package/src/runtime/trust-verdict-consumer.ts +3 -3
  394. package/src/schedule/__tests__/worker-feature-flags.test.ts +58 -0
  395. package/src/schedule/retry-policy.ts +8 -8
  396. package/src/schedule/run-script.ts +14 -23
  397. package/src/schedule/schedule-recovery.ts +0 -1
  398. package/src/schedule/scheduler.ts +89 -51
  399. package/src/schedule/worker.ts +57 -0
  400. package/src/security/__tests__/openai-device-auth.test.ts +355 -0
  401. package/src/security/ces-rpc-record-backend.ts +123 -0
  402. package/src/security/oauth2.ts +7 -0
  403. package/src/security/openai-device-auth.ts +364 -0
  404. package/src/security/secure-keys.ts +15 -0
  405. package/src/skills/available-skills.ts +10 -1
  406. package/src/skills/catalog-install.ts +19 -1
  407. package/src/skills/inline-command-runner.ts +6 -2
  408. package/src/skills/platform-compatibility.test.ts +49 -0
  409. package/src/skills/platform-compatibility.ts +65 -0
  410. package/src/skills/tool-manifest.ts +20 -0
  411. package/src/stt/__tests__/roles.test.ts +152 -0
  412. package/src/stt/daemon-batch-transcriber.ts +4 -1
  413. package/src/stt/roles.ts +180 -0
  414. package/src/stt/stt-stream-session.ts +3 -2
  415. package/src/stt/types.ts +33 -3
  416. package/src/subagent/manager.ts +12 -0
  417. package/src/telemetry/__tests__/live-voice-funnel.test.ts +34 -0
  418. package/src/telemetry/live-voice-funnel.ts +12 -2
  419. package/src/telemetry/telemetry-wire.generated.ts +6 -0
  420. package/src/telemetry/tool-audit.ts +83 -30
  421. package/src/telemetry/tool-executed-events-store.test.ts +0 -1
  422. package/src/telemetry/turn-outcome.ts +75 -4
  423. package/src/tools/client-os.ts +115 -0
  424. package/src/tools/computer-use/definitions.ts +11 -7
  425. package/src/tools/computer-use/skill-proxy-bridge.ts +2 -2
  426. package/src/tools/credentials/broker-types.ts +0 -71
  427. package/src/tools/credentials/broker.ts +6 -201
  428. package/src/tools/credentials/metadata-store.ts +65 -16
  429. package/src/tools/credentials/store.ts +2 -0
  430. package/src/tools/executor.ts +2 -8
  431. package/src/tools/host-shell.test.ts +75 -0
  432. package/src/tools/host-shell.ts +24 -0
  433. package/src/tools/host-terminal/host-shell.ts +15 -44
  434. package/src/tools/mcp/mcp-tool-factory.ts +44 -9
  435. package/src/tools/permission-checker.ts +73 -21
  436. package/src/tools/policy-context.ts +1 -0
  437. package/src/tools/shared/filesystem/image-read.ts +1 -1
  438. package/src/tools/skills/find-similar-skills.ts +10 -2
  439. package/src/tools/skills/load.ts +41 -6
  440. package/src/tools/skills/sandbox-runner.ts +8 -16
  441. package/src/tools/skills/skill-tool-factory.ts +1 -0
  442. package/src/tools/terminal/__tests__/safe-env.test.ts +64 -1
  443. package/src/tools/terminal/safe-env.ts +39 -9
  444. package/src/tools/terminal/shell.ts +12 -31
  445. package/src/tools/tool-approval-handler.ts +64 -10
  446. package/src/tools/tool-types.ts +10 -0
  447. package/src/tools/types.ts +19 -3
  448. package/src/tools/ui-surface/surface-shape-docs.ts +4 -3
  449. package/src/tools/workflows/manage-workflows.ts +2 -2
  450. package/src/tools/workflows/run-workflow.test.ts +2 -1
  451. package/src/util/__tests__/file-use.test.ts +61 -0
  452. package/src/util/__tests__/process-table.test.ts +66 -0
  453. package/src/util/__tests__/process-tree.test.ts +46 -0
  454. package/src/util/errors.ts +1 -0
  455. package/src/util/file-use.ts +91 -0
  456. package/src/util/host-process.test.ts +17 -0
  457. package/src/util/host-process.ts +75 -0
  458. package/src/util/image-conversion.ts +39 -7
  459. package/src/util/platform.ts +32 -13
  460. package/src/util/process-table.ts +249 -0
  461. package/src/util/process-tree.ts +85 -129
  462. package/src/util/provider-error-patterns.ts +20 -0
  463. package/src/util/spawn.ts +9 -2
  464. package/src/watch/__tests__/watch-retro.test.ts +48 -1
  465. package/src/watch/watch-retro.ts +46 -4
  466. package/src/workspace/git-service.ts +4 -14
  467. package/src/workspace/migrations/147-rename-colliding-backup-profile-names.ts +360 -0
  468. package/src/workspace/migrations/148-strip-unsupported-fallback-profiles.ts +117 -0
  469. package/src/workspace/migrations/149-repoint-backup-profile-selections.ts +204 -0
  470. package/src/workspace/migrations/150-stt-flux-provider-to-model-family.ts +139 -0
  471. package/src/workspace/migrations/__tests__/150-stt-flux-provider-to-model-family.test.ts +160 -0
  472. package/src/workspace/migrations/registry.ts +8 -0
  473. package/src/__tests__/permission-types.test.ts +0 -17
  474. package/src/channels/gateway-discord-admission.ts +0 -32
  475. package/src/persistence/fork-message-copy.ts +0 -239
  476. package/src/plugins/defaults/memory/__tests__/fork-message-copy.test.ts +0 -221
  477. package/src/security/__tests__/oauth2-device-code.test.ts +0 -431
  478. package/src/security/oauth2-device-code.ts +0 -312
@@ -1,4 +1,9 @@
1
- import { resolveCallSiteConfig } from "../config/llm-resolver.js";
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import {
4
+ resolveCallSiteConfig,
5
+ selectWinningProfile,
6
+ } from "../config/llm-resolver.js";
2
7
  import { getConfig } from "../config/loader.js";
3
8
  import {
4
9
  resolveUsageAttribution,
@@ -22,11 +27,23 @@ import {
22
27
  isAnthropicDelegatingGateway,
23
28
  isAnthropicModel,
24
29
  } from "./anthropic-gateway-shared.js";
30
+ import {
31
+ type BreakerObservation,
32
+ type BreakerRoute,
33
+ recordFallbackServed,
34
+ recordPrimaryFailure,
35
+ recordPrimarySuccess,
36
+ releaseRecoveryProbe,
37
+ shouldSkipPrimary,
38
+ tryAcquireRecoveryProbe,
39
+ } from "./fallback-breaker.js";
25
40
  import { resolveLogitBiasPreset } from "./inference/logit-bias.js";
26
41
  import {
27
42
  isAdaptiveThinkingOnlyModel,
28
43
  isAdaptiveThinkingUnsupportedModel,
29
44
  } from "./model-catalog.js";
45
+ import { buildOpenCodeRequestHeaders } from "./opencode/client.js";
46
+ import { dispatchProviderResolvable } from "./provider-resolvability.js";
30
47
  import {
31
48
  isThinkingConfigAdaptive,
32
49
  isThinkingConfigDisabled,
@@ -35,9 +52,11 @@ import {
35
52
  import {
36
53
  isContextOverflowError,
37
54
  type Message,
55
+ NATIVE_WEB_SEARCH_TOOL_NAME,
38
56
  type Provider,
39
57
  type ProviderResponse,
40
58
  type SendMessageOptions,
59
+ type ToolDefinition,
41
60
  } from "./types.js";
42
61
  import { UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE } from "./unparseable-tool-args.js";
43
62
 
@@ -287,6 +306,16 @@ function isRetryableTransportAbort(error: unknown): boolean {
287
306
  return RETRYABLE_TRANSPORT_ABORT_PATTERNS.some((p) => p.test(error.message));
288
307
  }
289
308
 
309
+ /**
310
+ * A daemon or user cancellation. The provider catch-sites tag these with
311
+ * `abortReason` exactly when `signal.aborted` was true at the time of failure,
312
+ * which is what separates them from transport-level aborts: both surface as
313
+ * "Request was aborted" from the SDK, and only the tag says who stopped it.
314
+ */
315
+ function isCallerAbort(error: unknown): boolean {
316
+ return error instanceof ProviderError && error.abortReason !== undefined;
317
+ }
318
+
290
319
  function isRetryableError(error: unknown): boolean {
291
320
  // Context overflow is deterministic — retrying the same oversized prompt
292
321
  // will never succeed. Short-circuit before the generic 429/5xx check so
@@ -295,12 +324,11 @@ function isRetryableError(error: unknown): boolean {
295
324
  if (isContextOverflowError(error)) {
296
325
  return false;
297
326
  }
298
- // Daemon/user-initiated aborts are never retryable. The catch-site tags
299
- // these with `abortReason` exactly when `signal.aborted` was true at the
300
- // time of failure, so this short-circuits before any message-based pattern
301
- // matches which matters because transport-level aborts (retryable) and
302
- // caller-cancels both surface as "Request was aborted" from the SDK.
303
- if (error instanceof ProviderError && error.abortReason !== undefined) {
327
+ // Daemon/user-initiated aborts are never retryable. This short-circuits
328
+ // before any message-based pattern matches, which matters because
329
+ // transport-level aborts (retryable) and caller-cancels both surface as
330
+ // "Request was aborted" from the SDK.
331
+ if (isCallerAbort(error)) {
304
332
  return false;
305
333
  }
306
334
  // Prefer the provider-stamped semantic reason: a known reason decides
@@ -330,6 +358,216 @@ function isRetryableError(error: unknown): boolean {
330
358
  return isRetryableNetworkError(error);
331
359
  }
332
360
 
361
+ /** Cap server-suggested delays at 60s. */
362
+ const MAX_RETRY_DELAY_MS = 60_000;
363
+
364
+ /**
365
+ * How long to wait before the next attempt, and whether the upstream named the
366
+ * wait itself. A server-provided `Retry-After` wins over exponential backoff,
367
+ * capped so a pathological header cannot stall a turn.
368
+ */
369
+ function retryPlan(
370
+ error: unknown,
371
+ attempt: number,
372
+ ): { delay: number; retryAfterHeader: boolean } {
373
+ const retryAfter =
374
+ error instanceof ProviderError ? error.retryAfterMs : undefined;
375
+ return {
376
+ delay: Math.min(
377
+ retryAfter ?? computeRetryDelay(attempt, DEFAULT_BASE_DELAY_MS),
378
+ MAX_RETRY_DELAY_MS,
379
+ ),
380
+ retryAfterHeader: retryAfter !== undefined,
381
+ };
382
+ }
383
+
384
+ /** Structured `errorType` for the "Retrying after transient error" logs. */
385
+ function retryErrorType(error: unknown): string {
386
+ if (error instanceof ProviderError && error.statusCode === 429) {
387
+ return "rate_limit";
388
+ }
389
+ if (
390
+ error instanceof ProviderError &&
391
+ error.statusCode !== undefined &&
392
+ error.statusCode >= 500
393
+ ) {
394
+ return `server_error_${error.statusCode}`;
395
+ }
396
+ if (isRetryableProviderMessage(error)) {
397
+ return "provider_overloaded";
398
+ }
399
+ if (isRetryableStreamError(error)) {
400
+ return "stream_corruption";
401
+ }
402
+ if (isRetryableTransportAbort(error)) {
403
+ return "transport_abort";
404
+ }
405
+ return "network_error";
406
+ }
407
+
408
+ /**
409
+ * The managed proxy's preflight guard rejects a model with no billing rate
410
+ * card using a 400 whose body carries this phrase (django
411
+ * `app/runtime_proxy/views.py`). It marks a model rename/retirement incident
412
+ * (a route problem, not a request problem), so it is fallback-eligible even
413
+ * though 400s are otherwise final.
414
+ */
415
+ const MANAGED_PROXY_UNSUPPORTED_MODEL_PATTERN =
416
+ /is not yet supported on the Vellum hosted service/;
417
+
418
+ /**
419
+ * Whether the failure indicts one model rather than the upstream serving it: a
420
+ * provider-classified `model_not_found`, a 404 with no definitive
421
+ * classification, or the managed proxy's preflight 400 for a renamed/retired
422
+ * model. As in `isRetryableError`, a provider-stamped semantic reason takes
423
+ * precedence over the status fallback: a 404 whose classifier assigned a
424
+ * definitive non-model reason (Anthropic, for example, stamps `bad_request` on
425
+ * a 404 without a model signal) marks a deterministic request/routing failure
426
+ * that a different model route would not fix. Only an absent or `unknown`
427
+ * reason falls through to the raw 404 check. `model_restricted` is a
428
+ * credential/policy denial, not a missing model, so it is deliberately absent.
429
+ */
430
+ function isModelSpecificError(error: unknown): boolean {
431
+ if (!(error instanceof ProviderError)) {
432
+ return false;
433
+ }
434
+ if (error.reason === "model_not_found") {
435
+ return true;
436
+ }
437
+ if (
438
+ error.statusCode === 404 &&
439
+ (error.reason === undefined || error.reason === "unknown")
440
+ ) {
441
+ return true;
442
+ }
443
+ return (
444
+ error.statusCode === 400 &&
445
+ MANAGED_PROXY_UNSUPPORTED_MODEL_PATTERN.test(error.message)
446
+ );
447
+ }
448
+
449
+ /** Outage-shaped failures that justify switching to a backup profile. */
450
+ function isFallbackEligibleError(
451
+ error: unknown,
452
+ opts: {
453
+ retriesExhausted: boolean;
454
+ credentialSource?: ProviderCredentialSource;
455
+ },
456
+ ): boolean {
457
+ // Deterministic request failures and caller-initiated aborts never justify
458
+ // a different route (same short-circuits as `isRetryableError`): the same
459
+ // oversized prompt overflows the backup too, and a cancelled request must
460
+ // stay cancelled.
461
+ if (isContextOverflowError(error)) {
462
+ return false;
463
+ }
464
+ if (isCallerAbort(error)) {
465
+ return false;
466
+ }
467
+ // (a) The retry loop burned its whole budget on a transient error
468
+ // (429/5xx/overloaded/network/transport-abort): the primary route is down.
469
+ if (opts.retriesExhausted && isRetryableError(error)) {
470
+ return true;
471
+ }
472
+ if (!(error instanceof ProviderError)) {
473
+ return false;
474
+ }
475
+ // (b) Invalid managed credential (the invalid-key incident). Applies only
476
+ // to `vellum-managed` routes, where the platform owns the credential and a
477
+ // broken key is a platform incident. A BYOK or OAuth-subscription route
478
+ // with a broken personal credential must surface the auth error so the
479
+ // user can fix it, not silently reroute to a differently billed backup.
480
+ // Only reached after `sendMessage`'s credential-refresh path has already
481
+ // been attempted for managed routes: same status/reason gate as
482
+ // `shouldRefreshManagedCredential`.
483
+ if (
484
+ opts.credentialSource === "vellum-managed" &&
485
+ (error.statusCode === 401 || error.statusCode === 403) &&
486
+ (error.reason === undefined ||
487
+ error.reason === "unknown" ||
488
+ error.reason === "invalid_credentials")
489
+ ) {
490
+ return true;
491
+ }
492
+ // (c) The model is gone (a rename/retirement incident): a different model's
493
+ // route is exactly what fixes it. See `isModelSpecificError` for which
494
+ // shapes qualify and which deliberately do not.
495
+ return isModelSpecificError(error);
496
+ }
497
+
498
+ /** Structured error class for the "Falling back to backup profile" log. */
499
+ function fallbackErrorType(error: unknown, retriesExhausted: boolean): string {
500
+ if (retriesExhausted) {
501
+ return "retries_exhausted";
502
+ }
503
+ if (error instanceof ProviderError) {
504
+ if (error.statusCode === 401 || error.statusCode === 403) {
505
+ return "invalid_credentials";
506
+ }
507
+ if (error.statusCode === 404 || error.statusCode === 400) {
508
+ return "model_not_found";
509
+ }
510
+ }
511
+ return "unknown";
512
+ }
513
+
514
+ /**
515
+ * Whether a failed request can be re-routed to a backup profile. Fallback
516
+ * re-resolves the ORIGINAL caller options with the backup profile forced,
517
+ * which requires a `callSite`-bearing config. An explicit per-call
518
+ * route pin (`model`, `provider`, or `provider_connection`) disqualifies the
519
+ * request, whether it came from this call or the persisted call-site config.
520
+ * Silently serving a different route would violate user intent: a profile
521
+ * user asked for a tier, while a pinning user asked for an exact route. Pinned
522
+ * calls keep retry-then-error behavior.
523
+ */
524
+ const EXACT_ROUTE_PIN_KEYS = [
525
+ "model",
526
+ "provider",
527
+ "provider_connection",
528
+ ] as const;
529
+
530
+ function hasExactRoutePin(
531
+ config: Record<string, unknown> | undefined,
532
+ ): boolean {
533
+ return EXACT_ROUTE_PIN_KEYS.some((key) => {
534
+ const value = config?.[key];
535
+ return typeof value === "string" && value.trim().length > 0;
536
+ });
537
+ }
538
+
539
+ function canReRouteToFallbackProfile(options?: SendMessageOptions): boolean {
540
+ const config = options?.config;
541
+ if (config?.callSite === undefined) {
542
+ return false;
543
+ }
544
+ if (hasExactRoutePin(config)) {
545
+ return false;
546
+ }
547
+ const callSiteConfig = getConfig().llm.callSites?.[config.callSite] as
548
+ | Record<string, unknown>
549
+ | undefined;
550
+ return !hasExactRoutePin(callSiteConfig);
551
+ }
552
+
553
+ /**
554
+ * The route a failure indicts, or null when it must not be remembered at all.
555
+ * An outage marks the whole upstream; a retired or renamed model marks only
556
+ * that model, because diverting every healthy profile on the upstream for one
557
+ * model's 404 does more damage than the incident. A model-specific failure on
558
+ * a request whose model cannot be named is not remembered either: naming the
559
+ * upstream instead would be exactly that over-trip.
560
+ */
561
+ function failureBreakerRoute(
562
+ route: BreakerRoute,
563
+ error: unknown,
564
+ ): BreakerRoute | null {
565
+ if (!isModelSpecificError(error)) {
566
+ return { upstream: route.upstream };
567
+ }
568
+ return route.model === undefined ? null : route;
569
+ }
570
+
333
571
  /**
334
572
  * Whether the request lands on Anthropic's Messages API wire: direct Anthropic
335
573
  * calls, plus OpenRouter / Vercel AI Gateway calls that delegate `anthropic/*`
@@ -383,6 +621,7 @@ function normalizeSendMessageOptions(
383
621
  // must never leak into provider JSON request bodies.
384
622
  delete nextConfig.usageAttributionHeaders;
385
623
  delete nextConfig.usageTracking;
624
+ delete nextConfig.requestHeaders;
386
625
 
387
626
  // Preserve the per-conversation prompt-cache key before `selectionSeed` is
388
627
  // stripped below. Gated to providers whose Responses transport consumes it
@@ -401,17 +640,32 @@ function normalizeSendMessageOptions(
401
640
  nextConfig.promptCacheKey = config.selectionSeed;
402
641
  }
403
642
 
404
- // `overrideProfile`, `forceOverrideProfile`, `selectionSeed`, and
405
- // `conversationId` are routing/resolution-time concerns (consumed by the
406
- // resolver below, `CallSiteRoutingProvider`'s provider selection, and
407
- // `UsageTrackingProvider`'s ledger attribution); none is a wire-format
408
- // field. Strip unconditionally (after the `openai` promptCacheKey copy
409
- // above) so they never leak into provider request bodies even when callers
410
- // set them without a `callSite`.
643
+ if (providerName === "opencode") {
644
+ const conversationId =
645
+ typeof config.conversationId === "string"
646
+ ? config.conversationId
647
+ : undefined;
648
+ const requestHeaders = buildOpenCodeRequestHeaders({
649
+ conversationId,
650
+ requestId: randomUUID(),
651
+ });
652
+ if (Object.keys(requestHeaders).length > 0) {
653
+ nextConfig.requestHeaders = requestHeaders;
654
+ }
655
+ }
656
+
657
+ // `overrideProfile`, `forceOverrideProfile`, `selectionSeed`,
658
+ // `conversationId`, and `nativeWebSearchSentinel` are routing/resolution-time
659
+ // concerns (consumed by the resolver below, `CallSiteRoutingProvider`'s
660
+ // provider selection, `UsageTrackingProvider`'s ledger attribution, and the
661
+ // fallback tool filter); none is a wire-format field. Strip unconditionally
662
+ // (after the `openai` promptCacheKey copy above) so they never leak into
663
+ // provider request bodies even when callers set them without a `callSite`.
411
664
  delete nextConfig.overrideProfile;
412
665
  delete nextConfig.forceOverrideProfile;
413
666
  delete nextConfig.selectionSeed;
414
667
  delete nextConfig.conversationId;
668
+ delete nextConfig.nativeWebSearchSentinel;
415
669
 
416
670
  if (config.callSite !== undefined) {
417
671
  const resolved = resolveCallSiteConfig(config.callSite, getConfig().llm, {
@@ -892,6 +1146,32 @@ export class RetryProvider implements Provider {
892
1146
  credentialSource?: ProviderCredentialSource;
893
1147
  connectionName?: string;
894
1148
  refreshCredentialProvider?: () => Promise<Provider | null>;
1149
+ /**
1150
+ * Escalation hook: resolve a backup route (a ready adapter, the backup
1151
+ * profile key, and the backup route's usage-attribution forwarding
1152
+ * policy) for a request whose primary route failed with an
1153
+ * outage-shaped error (see {@link isFallbackEligibleError}). The
1154
+ * callback owns all profile knowledge: it inspects the failed call's
1155
+ * winning profile and returns null when that profile declares no
1156
+ * `fallbackProfile`, mirroring how `refreshCredentialProvider` keeps
1157
+ * this wrapper ignorant of credential storage.
1158
+ * `forwardUsageAttributionHeaders` must describe the BACKUP route, not
1159
+ * the primary: managed-proxy routes pass true, BYOK and other
1160
+ * third-party routes pass false. The fallback send normalizes with the
1161
+ * returned policy so `X-Vellum-*` billing metadata never leaks to a
1162
+ * third party and is never omitted from the managed proxy. One hop max:
1163
+ * the returned adapter must be RAW, so the backup can never escalate
1164
+ * again whatever happens to it. Whether the backup send gets a retry
1165
+ * budget depends on which entry point escalated; see
1166
+ * `sendOnFallbackRoute`.
1167
+ */
1168
+ resolveFallbackRoute?: (
1169
+ failedOptions: SendMessageOptions | undefined,
1170
+ ) => Promise<{
1171
+ provider: Provider;
1172
+ overrideProfile: string;
1173
+ forwardUsageAttributionHeaders: boolean;
1174
+ } | null>;
895
1175
  } = {},
896
1176
  ) {
897
1177
  this.inner = inner;
@@ -913,6 +1193,40 @@ export class RetryProvider implements Provider {
913
1193
  );
914
1194
  }
915
1195
 
1196
+ /**
1197
+ * Reload the managed credential after an auth rejection and swap the inner
1198
+ * provider for one built around it, so a key rotated out of band is picked
1199
+ * up without a restart. Returns true when a refreshed adapter took over and
1200
+ * the request is worth attempting again. Never throws: a failed reload
1201
+ * leaves the original error to surface.
1202
+ */
1203
+ private async refreshManagedCredential(): Promise<boolean> {
1204
+ try {
1205
+ const refreshed = await this.options.refreshCredentialProvider?.();
1206
+ if (refreshed) {
1207
+ this.inner = refreshed;
1208
+ log.info(
1209
+ {
1210
+ provider: this.name,
1211
+ connectionName: this.options.connectionName,
1212
+ },
1213
+ "Retrying managed inference with refreshed assistant credentials",
1214
+ );
1215
+ return true;
1216
+ }
1217
+ } catch (refreshError) {
1218
+ log.warn(
1219
+ {
1220
+ provider: this.name,
1221
+ connectionName: this.options.connectionName,
1222
+ refreshError,
1223
+ },
1224
+ "Failed to reload managed assistant credentials",
1225
+ );
1226
+ }
1227
+ return false;
1228
+ }
1229
+
916
1230
  private attributeCredential(error: unknown): void {
917
1231
  const { credentialSource, connectionName } = this.options;
918
1232
  if (
@@ -933,9 +1247,10 @@ export class RetryProvider implements Provider {
933
1247
  messages: Message[],
934
1248
  options?: SendMessageOptions,
935
1249
  ): Promise<ProviderResponse> {
936
- let didRetry = false;
937
1250
  let retryAttempt = 0;
938
1251
  let credentialRefreshAttempted = false;
1252
+ let correctiveResendAttempted = false;
1253
+ let fallbackAttempted = false;
939
1254
  let messagesForAttempt = messages;
940
1255
 
941
1256
  const normalizedOptions = normalizeSendMessageOptions(this.name, options, {
@@ -943,12 +1258,235 @@ export class RetryProvider implements Provider {
943
1258
  this.options.forwardUsageAttributionHeaders === true,
944
1259
  });
945
1260
 
1261
+ // Only a request that can actually take the backup consults the circuit
1262
+ // breaker: it needs a wired escalation hook (BYOK, oauth-subscription, and
1263
+ // no-auth routes have none, so they keep today's behavior exactly) and it
1264
+ // must be re-routable. One gate for reads and writes alike, so a request
1265
+ // that bypasses the breaker can neither be skipped by it nor close a trip
1266
+ // that re-routable traffic still needs.
1267
+ //
1268
+ // The model comes from the resolved options, since a retired model is
1269
+ // remembered per model rather than per upstream.
1270
+ const breakerRoute: BreakerRoute | null =
1271
+ this.options.resolveFallbackRoute !== undefined &&
1272
+ canReRouteToFallbackProfile(options)
1273
+ ? {
1274
+ upstream: this.name,
1275
+ ...(typeof normalizedOptions?.config?.model === "string"
1276
+ ? { model: normalizedOptions.config.model }
1277
+ : {}),
1278
+ }
1279
+ : null;
1280
+
1281
+ if (breakerRoute !== null) {
1282
+ if (shouldSkipPrimary(breakerRoute)) {
1283
+ // The primary is known to be down, so its retry budget would only add
1284
+ // latency to an answer the backup was always going to give.
1285
+ log.info(
1286
+ {
1287
+ provider: this.name,
1288
+ connectionName: this.options.connectionName,
1289
+ },
1290
+ "Skipping the primary route while its fallback breaker is open",
1291
+ );
1292
+ fallbackAttempted = true;
1293
+ const served = await this.sendOnFallbackRoute(
1294
+ messages,
1295
+ options,
1296
+ undefined,
1297
+ false,
1298
+ // The rule this argument encodes: a request gets a retry budget on
1299
+ // the backup only when it has not already spent one. This request
1300
+ // skips the primary outright, so it has spent nothing, and on a
1301
+ // single attempt a lone 429 or mid-stream cut would fail the turn.
1302
+ { backupRetryBudget: true },
1303
+ );
1304
+ if (served !== null) {
1305
+ return served;
1306
+ }
1307
+ // No backup route applies after all (the config changed under the
1308
+ // remembered outage), so the primary is the only route left. The
1309
+ // escalation path below stays disabled: it would resolve the same
1310
+ // options against the same config and get the same nothing.
1311
+ } else if (tryAcquireRecoveryProbe(breakerRoute)) {
1312
+ log.info(
1313
+ {
1314
+ provider: this.name,
1315
+ connectionName: this.options.connectionName,
1316
+ model: breakerRoute.model,
1317
+ },
1318
+ "Probing the primary route for recovery",
1319
+ );
1320
+ // One probe, one attempt: no retry loop. Two one-shot repairs are the
1321
+ // exception, both because the probe would otherwise misread its own
1322
+ // failure as the route still being down. A managed credential that
1323
+ // expired during the outage is refreshed, or the route could never come
1324
+ // back. Malformed tool-argument JSON gets the same corrective note the
1325
+ // retry loop appends, because that failure is conditioned on the
1326
+ // request rather than the route.
1327
+ //
1328
+ // The probe ends the moment it reports a verdict. What happens to the
1329
+ // request that carried it then depends on that verdict: an outage sends
1330
+ // it to the backup, and a recovery hands it back to the ordinary retry
1331
+ // loop below, which is now the right place for it because the route it
1332
+ // just cleared is the one that loop sends to.
1333
+ //
1334
+ // Every send the probe makes is counted, repairs included. A repair is
1335
+ // still a send against the primary, so it is what the seed below has to
1336
+ // be built from: a constant would only be right on the path where no
1337
+ // repair ran.
1338
+ let probeSends = 0;
1339
+ while (true) {
1340
+ try {
1341
+ probeSends += 1;
1342
+ const response = await this.inner.sendMessage(
1343
+ messagesForAttempt,
1344
+ normalizedOptions,
1345
+ );
1346
+ releaseRecoveryProbe(breakerRoute, { verdict: "recovered" });
1347
+ return response;
1348
+ } catch (error) {
1349
+ // A cancelled request asked the route nothing, so the probe has no
1350
+ // verdict to report. Hand the claim back and leave the breaker as
1351
+ // it was: reporting recovery here would delete an entry nothing
1352
+ // retested and send the next request through the full retry budget
1353
+ // of a route still known to be down.
1354
+ if (isCallerAbort(error)) {
1355
+ releaseRecoveryProbe(breakerRoute, { verdict: "abandoned" });
1356
+ this.attributeCredential(error);
1357
+ throw error;
1358
+ }
1359
+ if (
1360
+ !credentialRefreshAttempted &&
1361
+ this.shouldRefreshManagedCredential(error)
1362
+ ) {
1363
+ credentialRefreshAttempted = true;
1364
+ if (await this.refreshManagedCredential()) {
1365
+ continue;
1366
+ }
1367
+ }
1368
+ // The same one-shot corrective resend the retry loop performs. A
1369
+ // byte-identical resend can reproduce malformed tool-argument JSON
1370
+ // indefinitely, so without the note the probe would report an
1371
+ // outage the route had nothing to do with. Skipped when the hint
1372
+ // has nowhere to go (an assistant prefill tail), since an
1373
+ // unchanged resend would only cost another round trip.
1374
+ if (
1375
+ !correctiveResendAttempted &&
1376
+ isUnparseableToolArgsError(error)
1377
+ ) {
1378
+ correctiveResendAttempted = true;
1379
+ const repaired = withUnparseableToolArgsHint(messages);
1380
+ if (repaired !== messages) {
1381
+ messagesForAttempt = repaired;
1382
+ continue;
1383
+ }
1384
+ }
1385
+ // The probe stands in for the whole retry budget while the breaker
1386
+ // is open, so a failure the retry loop would have exhausted itself
1387
+ // against means the outage continues. Any other failure means the
1388
+ // route answered the request, which is all the probe asked.
1389
+ //
1390
+ // A mid-stream corruption is such an answer: every pattern in
1391
+ // `RETRYABLE_STREAM_PATTERNS` requires an absent HTTP status, which
1392
+ // means the upstream accepted the request, returned 200, and
1393
+ // streamed content. The failure is in the bytes it produced, not in
1394
+ // its ability to serve, so it is evidence the primary is HEALTHY
1395
+ // and must not extend a remembered outage that can reach ten
1396
+ // minutes. A 429 is deliberately NOT treated the same way: the
1397
+ // route refused to do the work, no resend repairs it (only waiting
1398
+ // does, which is exactly what the cooldown provides), and reading a
1399
+ // rate limit as recovery would send the whole fleet back to a
1400
+ // primary that rejects every request. Provider-declared
1401
+ // `overloaded` and transport aborts stay outages for the same
1402
+ // reason: neither produced a usable answer.
1403
+ const outage =
1404
+ !isRetryableStreamError(error) &&
1405
+ isFallbackEligibleError(error, {
1406
+ retriesExhausted: true,
1407
+ credentialSource: this.options.credentialSource,
1408
+ });
1409
+ // The probe's own error decides what stays remembered, not the
1410
+ // scope of the entry it was acquired under: an upstream that
1411
+ // answers with a retired-model 404 has stopped being an outage,
1412
+ // and a model outage that turns into a 503 has stopped being about
1413
+ // the model.
1414
+ releaseRecoveryProbe(
1415
+ breakerRoute,
1416
+ outage
1417
+ ? {
1418
+ verdict: "failing",
1419
+ failedRoute: failureBreakerRoute(breakerRoute, error),
1420
+ }
1421
+ : { verdict: "recovered" },
1422
+ );
1423
+ this.attributeCredential(error);
1424
+ if (!outage) {
1425
+ // The route answered, the breaker is closed, and this request is
1426
+ // an ordinary request again. A deterministic rejection (a plain
1427
+ // 400, a classified 404, a context overflow) is the route's real
1428
+ // answer and no resend changes it, so it surfaces as itself.
1429
+ if (!isRetryableError(error)) {
1430
+ throw error;
1431
+ }
1432
+ // Anything still standing here is the stream-corruption family
1433
+ // the exclusion above lets through: exactly the failure the main
1434
+ // loop repairs by resending, against a route that was just
1435
+ // cleared. Throwing it would sacrifice the request that carried
1436
+ // the probe to establish a verdict every LATER request gets to
1437
+ // use, so it falls through into the ordinary loop instead. That
1438
+ // loop also keeps the backup as its last resort, so a primary
1439
+ // that streams corruption all the way through still finishes the
1440
+ // turn somewhere.
1441
+ //
1442
+ // Every send the probe made WAS this request spending its own
1443
+ // attempts, so the loop starts that many attempts in. The loop
1444
+ // retries while `retryAttempt < DEFAULT_MAX_RETRIES`, so seeding
1445
+ // it with `probeSends` leaves `1 + (DEFAULT_MAX_RETRIES -
1446
+ // probeSends)` sends below and `DEFAULT_MAX_RETRIES + 1` in
1447
+ // total, for any number of probe sends: the same budget a request
1448
+ // that never probes gets. Counting sends rather than entries into
1449
+ // this branch is what holds that equality for a probe whose
1450
+ // repairs (a credential refresh, a corrective resend) each cost a
1451
+ // send of their own.
1452
+ retryAttempt = probeSends;
1453
+ break;
1454
+ }
1455
+ fallbackAttempted = true;
1456
+ const served = await this.sendOnFallbackRoute(
1457
+ messages,
1458
+ options,
1459
+ error,
1460
+ true,
1461
+ // The probe is one attempt on the primary, not a retry loop, so
1462
+ // this request has spent no retry budget either. Same reasoning
1463
+ // as the breaker-open skip above.
1464
+ { backupRetryBudget: true },
1465
+ );
1466
+ if (served !== null) {
1467
+ // No trip needed: the failed probe already re-tripped the breaker
1468
+ // with the longer cooldown a repeat outage earns.
1469
+ return served;
1470
+ }
1471
+ // The probe confirmed this route is still down. If the backup
1472
+ // cannot serve, surface that primary failure directly instead of
1473
+ // spending a fresh retry budget on the route the probe just
1474
+ // re-tripped.
1475
+ throw error;
1476
+ }
1477
+ }
1478
+ }
1479
+ }
1480
+
946
1481
  while (true) {
947
1482
  try {
948
1483
  const result = await this.inner.sendMessage(
949
1484
  messagesForAttempt,
950
1485
  normalizedOptions,
951
1486
  );
1487
+ if (breakerRoute !== null) {
1488
+ recordPrimarySuccess(breakerRoute);
1489
+ }
952
1490
  return result;
953
1491
  } catch (error) {
954
1492
  if (
@@ -956,28 +1494,8 @@ export class RetryProvider implements Provider {
956
1494
  this.shouldRefreshManagedCredential(error)
957
1495
  ) {
958
1496
  credentialRefreshAttempted = true;
959
- try {
960
- const refreshed = await this.options.refreshCredentialProvider?.();
961
- if (refreshed) {
962
- this.inner = refreshed;
963
- log.info(
964
- {
965
- provider: this.name,
966
- connectionName: this.options.connectionName,
967
- },
968
- "Retrying managed inference with refreshed assistant credentials",
969
- );
970
- continue;
971
- }
972
- } catch (refreshError) {
973
- log.warn(
974
- {
975
- provider: this.name,
976
- connectionName: this.options.connectionName,
977
- refreshError,
978
- },
979
- "Failed to reload managed assistant credentials",
980
- );
1497
+ if (await this.refreshManagedCredential()) {
1498
+ continue;
981
1499
  }
982
1500
  }
983
1501
 
@@ -990,61 +1508,431 @@ export class RetryProvider implements Provider {
990
1508
  messagesForAttempt = withUnparseableToolArgsHint(messages);
991
1509
  }
992
1510
  // Prefer server-provided Retry-After; fall back to exponential backoff.
993
- const retryAfter =
994
- error instanceof ProviderError ? error.retryAfterMs : undefined;
995
- const MAX_RETRY_DELAY_MS = 60_000; // Cap server-suggested delays at 60s
996
- const delay = Math.min(
997
- retryAfter ??
998
- computeRetryDelay(retryAttempt, DEFAULT_BASE_DELAY_MS),
999
- MAX_RETRY_DELAY_MS,
1000
- );
1001
- const errorType =
1002
- error instanceof ProviderError && error.statusCode === 429
1003
- ? "rate_limit"
1004
- : error instanceof ProviderError &&
1005
- error.statusCode !== undefined &&
1006
- error.statusCode >= 500
1007
- ? `server_error_${error.statusCode}`
1008
- : isRetryableProviderMessage(error)
1009
- ? "provider_overloaded"
1010
- : isRetryableStreamError(error)
1011
- ? "stream_corruption"
1012
- : isRetryableTransportAbort(error)
1013
- ? "transport_abort"
1014
- : "network_error";
1511
+ const { delay, retryAfterHeader } = retryPlan(error, retryAttempt);
1015
1512
  log.warn(
1016
1513
  {
1017
1514
  attempt: retryAttempt + 1,
1018
1515
  maxRetries: DEFAULT_MAX_RETRIES,
1019
1516
  delay,
1020
- retryAfterHeader: retryAfter !== undefined,
1021
- errorType,
1517
+ retryAfterHeader,
1518
+ errorType: retryErrorType(error),
1022
1519
  correctiveHint: messagesForAttempt !== messages,
1023
1520
  provider: this.name,
1024
1521
  message: error instanceof Error ? error.message : String(error),
1025
1522
  },
1026
1523
  "Retrying after transient error",
1027
1524
  );
1028
- didRetry = true;
1029
1525
  retryAttempt++;
1030
1526
  await sleep(delay);
1031
1527
  continue;
1032
1528
  }
1033
1529
 
1034
1530
  // If we exhausted retries on a retryable error, tag the error so
1035
- // downstream consumers (Sentry capture, etc.) can recognize that the
1036
- // retry loop already tried its best. The catch-site logic above only
1037
- // stops retrying when either (a) retries are exhausted, or (b) the
1038
- // error isn't retryable so we check the retryable predicate here to
1039
- // distinguish the two cases.
1040
- if (didRetry && isRetryableError(error) && error instanceof Error) {
1531
+ // downstream consumers (Sentry capture, escalation eligibility) can
1532
+ // recognize that the retry loop already tried its best. Control
1533
+ // reaches here for two reasons only, and the retryable predicate is
1534
+ // what separates them: either the budget is gone, or the error was
1535
+ // never retryable in the first place.
1536
+ //
1537
+ // Exhaustion is read off the same counter the retry guard above reads,
1538
+ // never off whether this loop happened to perform a retry itself. A
1539
+ // request can arrive here with its budget already consumed elsewhere:
1540
+ // a recovery probe seeds `retryAttempt` with the sends it made, so a
1541
+ // probe that spent the budget on its own repairs leaves the loop below
1542
+ // no retry to perform and would otherwise look like a request that had
1543
+ // never tried at all. Reading the counter keeps the two definitions
1544
+ // from drifting apart however the seed changes.
1545
+ const retriesExhausted =
1546
+ retryAttempt >= DEFAULT_MAX_RETRIES && isRetryableError(error);
1547
+ if (retriesExhausted && error instanceof Error) {
1041
1548
  (error as Error & { retriesExhausted?: boolean }).retriesExhausted =
1042
1549
  true;
1043
1550
  }
1044
1551
 
1045
1552
  this.attributeCredential(error);
1553
+
1554
+ // Last resort before rethrowing: escalate an outage-shaped failure to
1555
+ // the backup profile's route when the construction site wired one in.
1556
+ // One hop max; a request pinned to an explicit model never re-routes.
1557
+ if (
1558
+ !fallbackAttempted &&
1559
+ this.options.resolveFallbackRoute !== undefined &&
1560
+ canReRouteToFallbackProfile(options) &&
1561
+ isFallbackEligibleError(error, {
1562
+ retriesExhausted,
1563
+ credentialSource: this.options.credentialSource,
1564
+ })
1565
+ ) {
1566
+ fallbackAttempted = true;
1567
+ // What the failure indicts: the whole upstream for an outage, only
1568
+ // this model for a retirement or rename.
1569
+ const failedRoute =
1570
+ breakerRoute === null
1571
+ ? null
1572
+ : failureBreakerRoute(breakerRoute, error);
1573
+ let failureObservation: BreakerObservation | undefined;
1574
+ if (failedRoute !== null) {
1575
+ failureObservation = recordPrimaryFailure(failedRoute);
1576
+ }
1577
+ const fallbackResult = await this.sendOnFallbackRoute(
1578
+ messages,
1579
+ options,
1580
+ error,
1581
+ retriesExhausted,
1582
+ // No budget here: the primary loop above runs to a definitive
1583
+ // verdict before reaching this point, either exhausting its whole
1584
+ // budget against a transient failure or receiving an error no
1585
+ // resend changes. The user has waited through all of that, so the
1586
+ // backup answers once or the turn fails.
1587
+ { backupRetryBudget: false },
1588
+ );
1589
+ if (fallbackResult !== null) {
1590
+ // A completed backup serve is proof the primary is down and the
1591
+ // backup can carry the traffic, so later requests skip the retry
1592
+ // budget until a probe says the primary is back.
1593
+ if (failedRoute !== null) {
1594
+ recordFallbackServed(failedRoute, Date.now(), failureObservation);
1595
+ }
1596
+ return fallbackResult;
1597
+ }
1598
+ }
1599
+
1046
1600
  throw error;
1047
1601
  }
1048
1602
  }
1049
1603
  }
1604
+
1605
+ /**
1606
+ * The `tools` override for the fallback send, or nothing when the original
1607
+ * list carries over unchanged.
1608
+ *
1609
+ * The caller decided whether to append the native web search sentinel from
1610
+ * the PRIMARY route's capability (see `AgentLoop`), so a backup that runs
1611
+ * no server-side search would receive a tool it cannot execute and answer
1612
+ * with a tool call nothing can service. The sentinel is dropped for those
1613
+ * routes: degraded mode loses native search rather than the whole turn.
1614
+ *
1615
+ * Gated on `config.nativeWebSearchSentinel`, never on the name alone: with a
1616
+ * search backend like Brave or the platform search proxy configured, a tool
1617
+ * of the same name is app-executed and works on every route, so filtering it
1618
+ * would take away a capability the backup can still serve.
1619
+ *
1620
+ * `dropToolChoice` reports that filtering emptied the list. `AgentLoop` sets
1621
+ * `tool_choice: { type: "auto" }` under the same condition that appends the
1622
+ * sentinel, so a tool-less call site with native search enabled carries the
1623
+ * sentinel as its ONLY tool. Filtering it and leaving the paired
1624
+ * `tool_choice` behind would put a choice with nothing to choose from on the
1625
+ * wire. The Anthropic Messages API rejects that (its client spreads
1626
+ * `tool_choice` out of the request config whether or not any `tools`
1627
+ * survived), and the OpenAI Responses API would too if its client did not
1628
+ * happen to gate the field on a non-empty tool list. A recoverable outage
1629
+ * would become a hard 400 on the backup.
1630
+ *
1631
+ * Deliberately narrow: the flag is raised only for an EMPTY filtered list,
1632
+ * the one case that is invalid on the wire. A non-empty list keeps whatever
1633
+ * `tool_choice` the caller set. A conversation-level `toolChoice` takes
1634
+ * precedence over the sentinel's `auto` in `AgentLoop`, so it is caller
1635
+ * intent that a route change must not quietly discard, and the request it
1636
+ * produces is still valid.
1637
+ */
1638
+ private fallbackTools(
1639
+ options: SendMessageOptions | undefined,
1640
+ route: { provider: Provider },
1641
+ ): { tools?: ToolDefinition[]; dropToolChoice: boolean } {
1642
+ const tools = options?.tools;
1643
+ if (
1644
+ options?.config?.nativeWebSearchSentinel !== true ||
1645
+ tools === undefined ||
1646
+ !tools.some((tool) => tool.name === NATIVE_WEB_SEARCH_TOOL_NAME)
1647
+ ) {
1648
+ return { dropToolChoice: false };
1649
+ }
1650
+ const backupServesNativeSearch = route.provider.supportsNativeWebSearchFor
1651
+ ? route.provider.supportsNativeWebSearchFor(options)
1652
+ : route.provider.supportsNativeWebSearch === true;
1653
+ if (backupServesNativeSearch) {
1654
+ return { dropToolChoice: false };
1655
+ }
1656
+ const filtered = tools.filter(
1657
+ (tool) => tool.name !== NATIVE_WEB_SEARCH_TOOL_NAME,
1658
+ );
1659
+ return { tools: filtered, dropToolChoice: filtered.length === 0 };
1660
+ }
1661
+
1662
+ /**
1663
+ * Send on the backup adapter, optionally with a retry budget of its own.
1664
+ *
1665
+ * The one-hop rule is structural rather than conditional here: `route
1666
+ * .provider` is the RAW adapter the route callback built, with no
1667
+ * `RetryProvider` of its own and therefore no `resolveFallbackRoute`, so
1668
+ * nothing this loop calls can escalate to a second backup no matter how many
1669
+ * times it retries.
1670
+ *
1671
+ * `fallbackOptions` is sent verbatim on every attempt, never re-normalized.
1672
+ * `normalizeSendMessageOptions` has already consumed the `callSite` and
1673
+ * stamped the backup route's `usageAttributionHeaders`; running it again over
1674
+ * that now callSite-less config would delete the headers and have no way to
1675
+ * rebuild them, leaving degraded traffic unattributed on the platform's
1676
+ * billing events. This is the same reason the route callback hands back a raw
1677
+ * adapter instead of a wrapped one.
1678
+ */
1679
+ private async sendOnBackupAdapter(
1680
+ route: { provider: Provider },
1681
+ messages: Message[],
1682
+ fallbackOptions: SendMessageOptions | undefined,
1683
+ retryBudget: boolean,
1684
+ ): Promise<ProviderResponse> {
1685
+ let attempt = 0;
1686
+ let didRetry = false;
1687
+ let messagesForAttempt = messages;
1688
+ while (true) {
1689
+ try {
1690
+ return await route.provider.sendMessage(
1691
+ messagesForAttempt,
1692
+ fallbackOptions,
1693
+ );
1694
+ } catch (error) {
1695
+ if (
1696
+ !retryBudget ||
1697
+ attempt >= DEFAULT_MAX_RETRIES ||
1698
+ !isRetryableError(error)
1699
+ ) {
1700
+ // Same tagging contract as the primary loop, so a backup that flapped
1701
+ // its way through the whole budget is recognizable to Sentry capture
1702
+ // as noise no engineering action would change.
1703
+ if (didRetry && isRetryableError(error) && error instanceof Error) {
1704
+ (error as Error & { retriesExhausted?: boolean }).retriesExhausted =
1705
+ true;
1706
+ }
1707
+ throw error;
1708
+ }
1709
+ // Built from the original `messages` each time, so the corrective note
1710
+ // appears exactly once however many attempts fail this way.
1711
+ if (isUnparseableToolArgsError(error)) {
1712
+ messagesForAttempt = withUnparseableToolArgsHint(messages);
1713
+ }
1714
+ const { delay, retryAfterHeader } = retryPlan(error, attempt);
1715
+ log.warn(
1716
+ {
1717
+ attempt: attempt + 1,
1718
+ maxRetries: DEFAULT_MAX_RETRIES,
1719
+ delay,
1720
+ retryAfterHeader,
1721
+ errorType: retryErrorType(error),
1722
+ correctiveHint: messagesForAttempt !== messages,
1723
+ provider: this.name,
1724
+ backupProvider: route.provider.name,
1725
+ connectionName: this.options.connectionName,
1726
+ message: error instanceof Error ? error.message : String(error),
1727
+ },
1728
+ "Retrying the backup route after a transient error",
1729
+ );
1730
+ didRetry = true;
1731
+ attempt++;
1732
+ await sleep(delay);
1733
+ }
1734
+ }
1735
+ }
1736
+
1737
+ /**
1738
+ * Attempt the failed request on the backup route resolved by
1739
+ * `resolveFallbackRoute`. Returns null when no backup route applies (the
1740
+ * caller rethrows the original error unchanged); throws the fallback
1741
+ * error (with the original error attached as `cause`) when the backup
1742
+ * attempt itself fails.
1743
+ *
1744
+ * `originalError` is undefined when the circuit breaker skipped the primary
1745
+ * outright: there is no failure of this request to report or to attach, only
1746
+ * the remembered outage of an earlier one.
1747
+ *
1748
+ * `backupRetryBudget` says whether the backup send gets a retry loop of its
1749
+ * own. It is the caller's answer to one question: has this request already
1750
+ * spent a retry budget somewhere? See the three call sites for the reasoning
1751
+ * behind each answer.
1752
+ */
1753
+ private async sendOnFallbackRoute(
1754
+ messages: Message[],
1755
+ options: SendMessageOptions | undefined,
1756
+ originalError: unknown,
1757
+ retriesExhausted: boolean,
1758
+ { backupRetryBudget }: { backupRetryBudget: boolean },
1759
+ ): Promise<ProviderResponse | null> {
1760
+ let route: {
1761
+ provider: Provider;
1762
+ overrideProfile: string;
1763
+ forwardUsageAttributionHeaders: boolean;
1764
+ } | null;
1765
+ try {
1766
+ route = (await this.options.resolveFallbackRoute?.(options)) ?? null;
1767
+ } catch (resolveError) {
1768
+ log.warn(
1769
+ { provider: this.name, resolveError },
1770
+ "Failed to resolve fallback route; rethrowing the original error",
1771
+ );
1772
+ return null;
1773
+ }
1774
+ if (route === null) {
1775
+ return null;
1776
+ }
1777
+
1778
+ // Reject a mix backup profile outright. The fallback schema already
1779
+ // forbids `fallbackProfile` from referencing a mix, but this wrapper must
1780
+ // not trust that: honoring one would require the route callback, the
1781
+ // winner-selection guard below, and the re-normalization to agree on the
1782
+ // same seeded arm, and a request without a `selectionSeed` expands the
1783
+ // mix independently at each of those points, so one arm's model could be
1784
+ // sent through another arm's provider adapter. Treat it as non-applying
1785
+ // and surface the original error.
1786
+ if (getConfig().llm.profiles?.[route.overrideProfile]?.mix != null) {
1787
+ log.warn(
1788
+ {
1789
+ provider: this.name,
1790
+ connectionName: this.options.connectionName,
1791
+ overrideProfile: route.overrideProfile,
1792
+ },
1793
+ "Backup profile is a mix, which fallback routing does not support; rethrowing the original error",
1794
+ );
1795
+ return null;
1796
+ }
1797
+
1798
+ // Guard against a backup profile that does not actually apply: the
1799
+ // resolver skips a disabled, incomplete, or missing override profile and
1800
+ // falls through to the next rung (often the failed primary), while the
1801
+ // request would still dispatch on the backup adapter, producing a
1802
+ // provider/model mismatch. Verify the backup profile wins the winner
1803
+ // selection the re-normalization below will perform; if it does not,
1804
+ // surface the original error instead of sending a mismatched request.
1805
+ const failedConfig = options?.config;
1806
+ const callSite = failedConfig?.callSite;
1807
+ const selectionSeed = failedConfig?.selectionSeed;
1808
+ const selection =
1809
+ callSite === undefined
1810
+ ? null
1811
+ : selectWinningProfile(callSite, getConfig().llm, {
1812
+ overrideProfile: route.overrideProfile,
1813
+ ...(selectionSeed !== undefined ? { selectionSeed } : {}),
1814
+ isResolvableProvider: dispatchProviderResolvable,
1815
+ });
1816
+ if (
1817
+ selection === null ||
1818
+ selection.source !== "override" ||
1819
+ selection.profileName !== route.overrideProfile
1820
+ ) {
1821
+ log.warn(
1822
+ {
1823
+ provider: this.name,
1824
+ connectionName: this.options.connectionName,
1825
+ overrideProfile: route.overrideProfile,
1826
+ selectionSource: selection?.source,
1827
+ selectedProfile: selection?.profileName,
1828
+ },
1829
+ "Backup profile did not apply on re-resolution; rethrowing the original error",
1830
+ );
1831
+ return null;
1832
+ }
1833
+
1834
+ // Re-resolve the ORIGINAL caller options with the backup profile forced
1835
+ // (`resolveCallSiteConfig` floats a forced override to the top of the
1836
+ // selection chain). Explicit per-call `max_tokens`/`effort`/`thinking`
1837
+ // are cleared so the backup profile's resolved values win; the pin gate
1838
+ // in `canReRouteToFallbackProfile` already excluded explicit
1839
+ // `config.model`. Re-normalizing on a callSite-bearing config also
1840
+ // restamps the usage-attribution headers from the backup resolution, so
1841
+ // platform usage events attribute degraded traffic to the backup
1842
+ // profile. Whether those headers are forwarded at all follows the
1843
+ // FALLBACK route's policy, not the primary's: a managed primary falling
1844
+ // back to a third-party adapter must not leak billing metadata, and a
1845
+ // non-managed primary falling back to the managed proxy must include it.
1846
+ const fallbackConfig: Record<string, unknown> = { ...options?.config };
1847
+ delete fallbackConfig.max_tokens;
1848
+ delete fallbackConfig.effort;
1849
+ delete fallbackConfig.thinking;
1850
+ fallbackConfig.overrideProfile = route.overrideProfile;
1851
+ fallbackConfig.forceOverrideProfile = true;
1852
+ const { dropToolChoice, ...toolsOverride } = this.fallbackTools(
1853
+ options,
1854
+ route,
1855
+ );
1856
+ // Filtering the sentinel emptied the tool list, so the `tool_choice` the
1857
+ // call site paired with it now names a choice among no tools. See
1858
+ // `fallbackTools` for why that is a hard 400 rather than a no-op.
1859
+ if (dropToolChoice) {
1860
+ delete fallbackConfig.tool_choice;
1861
+ }
1862
+ const fallbackOptions = normalizeSendMessageOptions(
1863
+ route.provider.name,
1864
+ {
1865
+ ...options,
1866
+ config: fallbackConfig,
1867
+ ...toolsOverride,
1868
+ },
1869
+ {
1870
+ forwardUsageAttributionHeaders:
1871
+ route.forwardUsageAttributionHeaders === true,
1872
+ },
1873
+ );
1874
+
1875
+ const escalationCause =
1876
+ originalError === undefined
1877
+ ? { errorType: "breaker_open" }
1878
+ : {
1879
+ errorType: fallbackErrorType(originalError, retriesExhausted),
1880
+ message:
1881
+ originalError instanceof Error
1882
+ ? originalError.message
1883
+ : String(originalError),
1884
+ };
1885
+ log.warn(
1886
+ {
1887
+ provider: this.name,
1888
+ connectionName: this.options.connectionName,
1889
+ fallbackProvider: route.provider.name,
1890
+ overrideProfile: route.overrideProfile,
1891
+ ...escalationCause,
1892
+ },
1893
+ "Falling back to backup profile",
1894
+ );
1895
+
1896
+ try {
1897
+ const response = await this.sendOnBackupAdapter(
1898
+ route,
1899
+ messages,
1900
+ fallbackOptions,
1901
+ backupRetryBudget,
1902
+ );
1903
+ // Stamp the provider that actually served the response. Without this,
1904
+ // a backup adapter that does not set `actualProvider` leaves the outer
1905
+ // call-site router recording the success under the failed primary
1906
+ // provider (wrong provider, wrong pricing attribution). Never
1907
+ // overwrite a more specific value the adapter already set.
1908
+ if (response.actualProvider === undefined) {
1909
+ response.actualProvider = route.provider.name;
1910
+ }
1911
+ // Stamp the profile that actually governed the response for the same
1912
+ // reason: the outer `UsageTrackingProvider` resolves attribution from
1913
+ // the ORIGINAL request options, which still carry the failed primary's
1914
+ // resolution, so without this the usage event would bill the fallback
1915
+ // serve under the wrong profile. Never overwrite a more specific value
1916
+ // an inner wrapper already set.
1917
+ if (response.actualInferenceProfile === undefined) {
1918
+ response.actualInferenceProfile = route.overrideProfile;
1919
+ }
1920
+ return response;
1921
+ } catch (fallbackError) {
1922
+ if (originalError === undefined) {
1923
+ throw fallbackError;
1924
+ }
1925
+ log.warn(
1926
+ {
1927
+ provider: this.name,
1928
+ connectionName: this.options.connectionName,
1929
+ fallbackProvider: route.provider.name,
1930
+ overrideProfile: route.overrideProfile,
1931
+ fallbackError,
1932
+ },
1933
+ "Backup profile failed; rethrowing the original provider error",
1934
+ );
1935
+ return null;
1936
+ }
1937
+ }
1050
1938
  }