@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
@@ -0,0 +1,1093 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import * as retryUtil from "../util/retry.js";
4
+
5
+ // Instant sleep so exhausted-retry cases don't wait out real backoff delays;
6
+ // everything else is the real module.
7
+ mock.module("../util/retry.js", () => ({
8
+ ...retryUtil,
9
+ sleep: async () => {},
10
+ }));
11
+
12
+ import {
13
+ recordFallbackServed,
14
+ resetFallbackBreaker,
15
+ } from "../providers/fallback-breaker.js";
16
+ import type {
17
+ Message,
18
+ Provider,
19
+ ProviderResponse,
20
+ SendMessageOptions,
21
+ } from "../providers/types.js";
22
+ import { setConfig } from "./helpers/set-config.js";
23
+
24
+ const { RetryProvider } = await import("../providers/retry.js");
25
+ const { ContextOverflowError } = await import("../providers/types.js");
26
+ const { ProviderError } = await import("../util/errors.js");
27
+ const { DEFAULT_MAX_RETRIES } = await import("../util/retry.js");
28
+
29
+ // ── Fixtures ────────────────────────────────────────────────────────────────
30
+
31
+ const MESSAGES: Message[] = [
32
+ { role: "user", content: [{ type: "text", text: "hi" }] },
33
+ ];
34
+
35
+ // Primary route resolves through the active profile; the fallback forces the
36
+ // backup profile via `overrideProfile` + `forceOverrideProfile`, so the
37
+ // backup's model/maxTokens must win on the re-normalized options.
38
+ const LLM_FIXTURE = {
39
+ profiles: {
40
+ "primary-profile": {
41
+ source: "user",
42
+ provider: "openai",
43
+ model: "primary-model",
44
+ maxTokens: 1111,
45
+ },
46
+ "backup-profile": {
47
+ source: "user",
48
+ provider: "anthropic",
49
+ model: "backup-model",
50
+ maxTokens: 2222,
51
+ },
52
+ },
53
+ activeProfile: "primary-profile",
54
+ };
55
+
56
+ beforeEach(() => {
57
+ setConfig("llm", LLM_FIXTURE);
58
+ // The circuit breaker remembers a served fallback for the whole process, so
59
+ // a case that escalates would otherwise make the next case skip its primary.
60
+ resetFallbackBreaker();
61
+ });
62
+
63
+ function okResponse(model: string): ProviderResponse {
64
+ return {
65
+ content: [{ type: "text", text: "ok" }],
66
+ model,
67
+ usage: { inputTokens: 1, outputTokens: 1 },
68
+ stopReason: "end_turn",
69
+ } as ProviderResponse;
70
+ }
71
+
72
+ /** Primary stub that always throws `makeError()`. */
73
+ function failingProvider(
74
+ name: string,
75
+ makeError: () => unknown,
76
+ ): { provider: Provider; calls: () => number } {
77
+ let calls = 0;
78
+ const provider: Provider = {
79
+ name,
80
+ sendMessage: async () => {
81
+ calls += 1;
82
+ throw makeError();
83
+ },
84
+ };
85
+ return { provider, calls: () => calls };
86
+ }
87
+
88
+ /** Backup stub that records the options it was sent and serves a response. */
89
+ function backupProvider(
90
+ name = "anthropic",
91
+ opts: { supportsNativeWebSearch?: boolean } = {},
92
+ ): {
93
+ provider: Provider;
94
+ calls: () => number;
95
+ seenConfig: () => Record<string, unknown>;
96
+ seenToolNames: () => string[];
97
+ } {
98
+ let calls = 0;
99
+ let seen: SendMessageOptions | undefined;
100
+ const provider: Provider = {
101
+ name,
102
+ ...(opts.supportsNativeWebSearch === undefined
103
+ ? {}
104
+ : { supportsNativeWebSearch: opts.supportsNativeWebSearch }),
105
+ sendMessage: async (_messages: Message[], options?: SendMessageOptions) => {
106
+ calls += 1;
107
+ seen = options;
108
+ const config = options?.config as Record<string, unknown> | undefined;
109
+ return okResponse((config?.model as string | undefined) ?? "unresolved");
110
+ },
111
+ };
112
+ return {
113
+ provider,
114
+ calls: () => calls,
115
+ seenConfig: () => (seen?.config ?? {}) as Record<string, unknown>,
116
+ seenToolNames: () => (seen?.tools ?? []).map((tool) => tool.name),
117
+ };
118
+ }
119
+
120
+ function makeRoute(
121
+ provider: Provider,
122
+ {
123
+ overrideProfile = "backup-profile",
124
+ forwardUsageAttributionHeaders = true,
125
+ }: {
126
+ overrideProfile?: string;
127
+ forwardUsageAttributionHeaders?: boolean;
128
+ } = {},
129
+ ): {
130
+ resolveFallbackRoute: (
131
+ failedOptions: SendMessageOptions | undefined,
132
+ ) => Promise<{
133
+ provider: Provider;
134
+ overrideProfile: string;
135
+ forwardUsageAttributionHeaders: boolean;
136
+ } | null>;
137
+ calls: () => number;
138
+ } {
139
+ let calls = 0;
140
+ return {
141
+ resolveFallbackRoute: async () => {
142
+ calls += 1;
143
+ return { provider, overrideProfile, forwardUsageAttributionHeaders };
144
+ },
145
+ calls: () => calls,
146
+ };
147
+ }
148
+
149
+ async function captureError(promise: Promise<unknown>): Promise<unknown> {
150
+ try {
151
+ await promise;
152
+ } catch (error) {
153
+ return error;
154
+ }
155
+ throw new Error("expected the promise to reject");
156
+ }
157
+
158
+ // ── Tests ───────────────────────────────────────────────────────────────────
159
+
160
+ describe("RetryProvider fallback-route escalation", () => {
161
+ test("retries exhaust on 503 → callback invoked once, backup serves, headers restamped", async () => {
162
+ const primary = failingProvider(
163
+ "openai",
164
+ () => new ProviderError("Service Unavailable", "openai", 503),
165
+ );
166
+ const backup = backupProvider();
167
+ const route = makeRoute(backup.provider);
168
+ const wrapped = new RetryProvider(primary.provider, {
169
+ forwardUsageAttributionHeaders: true,
170
+ resolveFallbackRoute: route.resolveFallbackRoute,
171
+ });
172
+
173
+ const result = await wrapped.sendMessage(MESSAGES, {
174
+ config: { callSite: "mainAgent" },
175
+ });
176
+
177
+ // Full retry budget burned on the primary before escalating.
178
+ expect(primary.calls()).toBe(1 + DEFAULT_MAX_RETRIES);
179
+ expect(route.calls()).toBe(1);
180
+ expect(backup.calls()).toBe(1);
181
+ expect(result.model).toBe("backup-model");
182
+
183
+ // The re-normalized options carry the backup profile's resolved values...
184
+ const config = backup.seenConfig();
185
+ expect(config.model).toBe("backup-model");
186
+ expect(config.max_tokens).toBe(2222);
187
+ // ...with routing keys stripped before the fallback provider sees them.
188
+ expect(config.callSite).toBeUndefined();
189
+ expect(config.overrideProfile).toBeUndefined();
190
+ expect(config.forceOverrideProfile).toBeUndefined();
191
+ // Usage-attribution headers come from the backup resolution so platform
192
+ // usage events attribute degraded traffic to the backup profile.
193
+ const headers = config.usageAttributionHeaders as Record<string, string>;
194
+ expect(headers["X-Vellum-Inference-Profile"]).toBe("backup-profile");
195
+ expect(headers["X-Vellum-Resolved-Model"]).toBe("backup-model");
196
+ expect(headers["X-Vellum-Resolved-Provider"]).toBe("anthropic");
197
+ });
198
+
199
+ test("401 invalid_credentials after refresh fails → falls back without burning the retry budget", async () => {
200
+ const primary = failingProvider(
201
+ "openai",
202
+ () =>
203
+ new ProviderError("Unauthorized", "openai", 401, {
204
+ reason: "invalid_credentials",
205
+ }),
206
+ );
207
+ const backup = backupProvider();
208
+ const route = makeRoute(backup.provider);
209
+ let refreshCalls = 0;
210
+ const wrapped = new RetryProvider(primary.provider, {
211
+ credentialSource: "vellum-managed",
212
+ refreshCredentialProvider: async () => {
213
+ refreshCalls += 1;
214
+ return null;
215
+ },
216
+ resolveFallbackRoute: route.resolveFallbackRoute,
217
+ });
218
+
219
+ const result = await wrapped.sendMessage(MESSAGES, {
220
+ config: { callSite: "mainAgent" },
221
+ });
222
+
223
+ // The existing credential-refresh path ran first; the 401 is
224
+ // non-retryable, so the primary was attempted exactly once.
225
+ expect(refreshCalls).toBe(1);
226
+ expect(primary.calls()).toBe(1);
227
+ expect(route.calls()).toBe(1);
228
+ expect(result.model).toBe("backup-model");
229
+ });
230
+
231
+ test.each(["byok", "oauth-subscription", "no-auth", undefined] as const)(
232
+ "401 invalid_credentials on a %s route → callback never invoked, auth error surfaces",
233
+ async (credentialSource) => {
234
+ // The credential eligibility case is restricted to `vellum-managed`
235
+ // routes. A broken personal credential must surface so the user can
236
+ // fix it, not silently reroute to a differently billed backup.
237
+ const authError = new ProviderError("Unauthorized", "openai", 401, {
238
+ reason: "invalid_credentials",
239
+ });
240
+ const primary = failingProvider("openai", () => authError);
241
+ const route = makeRoute(backupProvider().provider);
242
+ const wrapped = new RetryProvider(primary.provider, {
243
+ ...(credentialSource !== undefined ? { credentialSource } : {}),
244
+ resolveFallbackRoute: route.resolveFallbackRoute,
245
+ });
246
+
247
+ const thrown = await captureError(
248
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
249
+ );
250
+
251
+ expect(thrown).toBe(authError);
252
+ expect(primary.calls()).toBe(1);
253
+ expect(route.calls()).toBe(0);
254
+ },
255
+ );
256
+
257
+ test("404 model-not-found (non-retryable) → falls back immediately", async () => {
258
+ const primary = failingProvider(
259
+ "openai",
260
+ () => new ProviderError("model not found", "openai", 404),
261
+ );
262
+ const backup = backupProvider();
263
+ const route = makeRoute(backup.provider);
264
+ const wrapped = new RetryProvider(primary.provider, {
265
+ resolveFallbackRoute: route.resolveFallbackRoute,
266
+ });
267
+
268
+ const result = await wrapped.sendMessage(MESSAGES, {
269
+ config: { callSite: "mainAgent" },
270
+ });
271
+
272
+ expect(primary.calls()).toBe(1);
273
+ expect(route.calls()).toBe(1);
274
+ expect(result.model).toBe("backup-model");
275
+ });
276
+
277
+ test.each(["unknown", undefined] as const)(
278
+ "404 with reason %s → status fallback applies, backup serves",
279
+ async (reason) => {
280
+ const primary = failingProvider(
281
+ "openai",
282
+ () =>
283
+ new ProviderError("not found", "openai", 404, {
284
+ ...(reason !== undefined ? { reason } : {}),
285
+ }),
286
+ );
287
+ const backup = backupProvider();
288
+ const route = makeRoute(backup.provider);
289
+ const wrapped = new RetryProvider(primary.provider, {
290
+ resolveFallbackRoute: route.resolveFallbackRoute,
291
+ });
292
+
293
+ const result = await wrapped.sendMessage(MESSAGES, {
294
+ config: { callSite: "mainAgent" },
295
+ });
296
+
297
+ expect(route.calls()).toBe(1);
298
+ expect(result.model).toBe("backup-model");
299
+ },
300
+ );
301
+
302
+ test("404 with provider-classified reason model_not_found → falls back immediately", async () => {
303
+ const primary = failingProvider(
304
+ "openai",
305
+ () =>
306
+ new ProviderError("model not found", "openai", 404, {
307
+ reason: "model_not_found",
308
+ }),
309
+ );
310
+ const backup = backupProvider();
311
+ const route = makeRoute(backup.provider);
312
+ const wrapped = new RetryProvider(primary.provider, {
313
+ resolveFallbackRoute: route.resolveFallbackRoute,
314
+ });
315
+
316
+ const result = await wrapped.sendMessage(MESSAGES, {
317
+ config: { callSite: "mainAgent" },
318
+ });
319
+
320
+ expect(primary.calls()).toBe(1);
321
+ expect(route.calls()).toBe(1);
322
+ expect(result.model).toBe("backup-model");
323
+ });
324
+
325
+ test("404 with a definitive non-model reason (bad_request) → callback never invoked", async () => {
326
+ // Anthropic classifies a 404 without a model signal as `bad_request`: a
327
+ // missing gateway resource or other deterministic request failure. The
328
+ // provider-stamped semantic reason takes precedence over the raw status,
329
+ // so the request must surface its real error instead of switching routes.
330
+ const requestError = new ProviderError("not found", "anthropic", 404, {
331
+ reason: "bad_request",
332
+ });
333
+ const primary = failingProvider("anthropic", () => requestError);
334
+ const route = makeRoute(backupProvider().provider);
335
+ const wrapped = new RetryProvider(primary.provider, {
336
+ resolveFallbackRoute: route.resolveFallbackRoute,
337
+ });
338
+
339
+ const thrown = await captureError(
340
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
341
+ );
342
+
343
+ expect(thrown).toBe(requestError);
344
+ expect(primary.calls()).toBe(1);
345
+ expect(route.calls()).toBe(0);
346
+ });
347
+
348
+ test("managed proxy preflight 400 for a retired model → falls back immediately", async () => {
349
+ const primary = failingProvider(
350
+ "openai",
351
+ () =>
352
+ new ProviderError(
353
+ "Model 'gpt-old' is not yet supported on the Vellum hosted service.",
354
+ "openai",
355
+ 400,
356
+ ),
357
+ );
358
+ const backup = backupProvider();
359
+ const route = makeRoute(backup.provider);
360
+ const wrapped = new RetryProvider(primary.provider, {
361
+ resolveFallbackRoute: route.resolveFallbackRoute,
362
+ });
363
+
364
+ const result = await wrapped.sendMessage(MESSAGES, {
365
+ config: { callSite: "mainAgent" },
366
+ });
367
+
368
+ expect(primary.calls()).toBe(1);
369
+ expect(route.calls()).toBe(1);
370
+ expect(result.model).toBe("backup-model");
371
+ });
372
+
373
+ test("plain 400 (request problem) → callback never invoked", async () => {
374
+ const primary = failingProvider(
375
+ "openai",
376
+ () => new ProviderError("invalid request", "openai", 400),
377
+ );
378
+ const route = makeRoute(backupProvider().provider);
379
+ const wrapped = new RetryProvider(primary.provider, {
380
+ resolveFallbackRoute: route.resolveFallbackRoute,
381
+ });
382
+
383
+ const thrown = await captureError(
384
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
385
+ );
386
+
387
+ expect((thrown as InstanceType<typeof ProviderError>).statusCode).toBe(400);
388
+ expect(route.calls()).toBe(0);
389
+ });
390
+
391
+ test("context overflow → callback never invoked", async () => {
392
+ const primary = failingProvider(
393
+ "openai",
394
+ () => new ContextOverflowError("prompt too long", "openai"),
395
+ );
396
+ const route = makeRoute(backupProvider().provider);
397
+ const wrapped = new RetryProvider(primary.provider, {
398
+ resolveFallbackRoute: route.resolveFallbackRoute,
399
+ });
400
+
401
+ const thrown = await captureError(
402
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
403
+ );
404
+
405
+ expect(thrown).toBeInstanceOf(ContextOverflowError);
406
+ expect(primary.calls()).toBe(1);
407
+ expect(route.calls()).toBe(0);
408
+ });
409
+
410
+ test("caller abort → callback never invoked", async () => {
411
+ const primary = failingProvider(
412
+ "openai",
413
+ () =>
414
+ new ProviderError("Request was aborted.", "openai", undefined, {
415
+ abortReason: "user_cancelled",
416
+ }),
417
+ );
418
+ const route = makeRoute(backupProvider().provider);
419
+ const wrapped = new RetryProvider(primary.provider, {
420
+ resolveFallbackRoute: route.resolveFallbackRoute,
421
+ });
422
+
423
+ const thrown = await captureError(
424
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
425
+ );
426
+
427
+ expect((thrown as InstanceType<typeof ProviderError>).abortReason).toBe(
428
+ "user_cancelled",
429
+ );
430
+ expect(primary.calls()).toBe(1);
431
+ expect(route.calls()).toBe(0);
432
+ });
433
+
434
+ test.each([
435
+ ["model", "pinned-model"],
436
+ ["provider", "openai"],
437
+ ["provider_connection", "vellum"],
438
+ ] as const)(
439
+ "explicit config.%s pin + outage-shaped error → callback never invoked, original error rethrown",
440
+ async (field, value) => {
441
+ const finalError = new ProviderError(
442
+ "Service Unavailable",
443
+ "openai",
444
+ 503,
445
+ );
446
+ const primary = failingProvider("openai", () => finalError);
447
+ const route = makeRoute(backupProvider().provider);
448
+ const wrapped = new RetryProvider(primary.provider, {
449
+ resolveFallbackRoute: route.resolveFallbackRoute,
450
+ });
451
+
452
+ const thrown = await captureError(
453
+ wrapped.sendMessage(MESSAGES, {
454
+ config: { callSite: "mainAgent", [field]: value },
455
+ }),
456
+ );
457
+
458
+ expect(thrown).toBe(finalError);
459
+ expect(route.calls()).toBe(0);
460
+ expect((thrown as { retriesExhausted?: boolean }).retriesExhausted).toBe(
461
+ true,
462
+ );
463
+ },
464
+ );
465
+
466
+ test.each([
467
+ ["model", "pinned-model"],
468
+ ["provider", "openai"],
469
+ ] as const)(
470
+ "persisted call-site %s pin + outage-shaped error → callback never invoked",
471
+ async (field, value) => {
472
+ setConfig("llm", {
473
+ ...LLM_FIXTURE,
474
+ callSites: { mainAgent: { [field]: value } },
475
+ });
476
+ const finalError = new ProviderError(
477
+ "Service Unavailable",
478
+ "openai",
479
+ 503,
480
+ );
481
+ const primary = failingProvider("openai", () => finalError);
482
+ const route = makeRoute(backupProvider().provider);
483
+ const wrapped = new RetryProvider(primary.provider, {
484
+ resolveFallbackRoute: route.resolveFallbackRoute,
485
+ });
486
+
487
+ const thrown = await captureError(
488
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
489
+ );
490
+
491
+ expect(thrown).toBe(finalError);
492
+ expect(route.calls()).toBe(0);
493
+ },
494
+ );
495
+
496
+ test("callback returns null (no fallbackProfile) → original error rethrown with retriesExhausted tagging", async () => {
497
+ const finalError = new ProviderError("Service Unavailable", "openai", 503);
498
+ const primary = failingProvider("openai", () => finalError);
499
+ let routeCalls = 0;
500
+ const wrapped = new RetryProvider(primary.provider, {
501
+ resolveFallbackRoute: async () => {
502
+ routeCalls += 1;
503
+ return null;
504
+ },
505
+ });
506
+
507
+ const thrown = await captureError(
508
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
509
+ );
510
+
511
+ expect(routeCalls).toBe(1);
512
+ expect(thrown).toBe(finalError);
513
+ expect((thrown as { retriesExhausted?: boolean }).retriesExhausted).toBe(
514
+ true,
515
+ );
516
+ });
517
+
518
+ test("route returned for a disabled backup profile → no send on the backup adapter, original error rethrown", async () => {
519
+ // A disabled override profile is skipped by winner selection, which
520
+ // would resolve back to the primary profile while the request still
521
+ // dispatches on the backup adapter (provider/model mismatch). The guard
522
+ // must refuse the fallback send and surface the original error.
523
+ setConfig("llm", {
524
+ ...LLM_FIXTURE,
525
+ profiles: {
526
+ ...LLM_FIXTURE.profiles,
527
+ "backup-profile": {
528
+ ...LLM_FIXTURE.profiles["backup-profile"],
529
+ status: "disabled",
530
+ },
531
+ },
532
+ });
533
+ const originalError = new ProviderError("model not found", "openai", 404);
534
+ const primary = failingProvider("openai", () => originalError);
535
+ const backup = backupProvider();
536
+ const route = makeRoute(backup.provider);
537
+ const wrapped = new RetryProvider(primary.provider, {
538
+ resolveFallbackRoute: route.resolveFallbackRoute,
539
+ });
540
+
541
+ const thrown = await captureError(
542
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
543
+ );
544
+
545
+ expect(thrown).toBe(originalError);
546
+ expect(route.calls()).toBe(1);
547
+ expect(backup.calls()).toBe(0);
548
+ });
549
+
550
+ test("route returned for an incomplete backup profile (no model) → no send on the backup adapter, original error rethrown", async () => {
551
+ setConfig("llm", {
552
+ ...LLM_FIXTURE,
553
+ profiles: {
554
+ ...LLM_FIXTURE.profiles,
555
+ "backup-profile": {
556
+ source: "user",
557
+ provider: "anthropic",
558
+ },
559
+ },
560
+ });
561
+ const originalError = new ProviderError("model not found", "openai", 404);
562
+ const primary = failingProvider("openai", () => originalError);
563
+ const backup = backupProvider();
564
+ const route = makeRoute(backup.provider);
565
+ const wrapped = new RetryProvider(primary.provider, {
566
+ resolveFallbackRoute: route.resolveFallbackRoute,
567
+ });
568
+
569
+ const thrown = await captureError(
570
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
571
+ );
572
+
573
+ expect(thrown).toBe(originalError);
574
+ expect(route.calls()).toBe(1);
575
+ expect(backup.calls()).toBe(0);
576
+ });
577
+
578
+ test("route returned for a mix backup profile → no send on the backup adapter, original error rethrown", async () => {
579
+ // The fallback schema forbids `fallbackProfile` from referencing a mix,
580
+ // and RetryProvider must not trust that: a mix backup without a
581
+ // `selectionSeed` would be re-expanded independently by the apply guard,
582
+ // the route callback, and the normalization, potentially sending one
583
+ // arm's model through another arm's provider adapter.
584
+ setConfig("llm", {
585
+ profiles: {
586
+ ...LLM_FIXTURE.profiles,
587
+ "arm-a": {
588
+ source: "user",
589
+ provider: "anthropic",
590
+ model: "arm-a-model",
591
+ },
592
+ "arm-b": {
593
+ source: "user",
594
+ provider: "openai",
595
+ model: "arm-b-model",
596
+ },
597
+ "backup-mix": {
598
+ source: "user",
599
+ mix: [
600
+ { profile: "arm-a", weight: 1 },
601
+ { profile: "arm-b", weight: 1 },
602
+ ],
603
+ },
604
+ },
605
+ activeProfile: "primary-profile",
606
+ });
607
+ const originalError = new ProviderError("model not found", "openai", 404);
608
+ const primary = failingProvider("openai", () => originalError);
609
+ const backup = backupProvider();
610
+ const route = makeRoute(backup.provider, { overrideProfile: "backup-mix" });
611
+ const wrapped = new RetryProvider(primary.provider, {
612
+ resolveFallbackRoute: route.resolveFallbackRoute,
613
+ });
614
+
615
+ const thrown = await captureError(
616
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
617
+ );
618
+
619
+ expect(thrown).toBe(originalError);
620
+ expect(route.calls()).toBe(1);
621
+ expect(backup.calls()).toBe(0);
622
+ });
623
+
624
+ test("backup also fails → original error is preserved, no second fallback attempt", async () => {
625
+ const originalError = new ProviderError("model not found", "openai", 404);
626
+ const primary = failingProvider("openai", () => originalError);
627
+ const fallbackError = new ProviderError("backup down", "anthropic", 500);
628
+ const backup = failingProvider("anthropic", () => fallbackError);
629
+ const route = makeRoute(backup.provider);
630
+ const wrapped = new RetryProvider(primary.provider, {
631
+ resolveFallbackRoute: route.resolveFallbackRoute,
632
+ });
633
+
634
+ const thrown = await captureError(
635
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
636
+ );
637
+
638
+ expect(thrown).toBe(originalError);
639
+ expect((thrown as Error).cause).toBeUndefined();
640
+ expect(route.calls()).toBe(1);
641
+ expect(backup.calls()).toBe(1);
642
+ });
643
+
644
+ test("failed recovery probe and backup surface the probe error without retrying the primary", async () => {
645
+ const originalError = new ProviderError(
646
+ "Service Unavailable",
647
+ "openai",
648
+ 503,
649
+ );
650
+ const primary = failingProvider("openai", () => originalError);
651
+ const backupError = new ProviderError("invalid request", "anthropic", 400);
652
+ const backup = failingProvider("anthropic", () => backupError);
653
+ const route = makeRoute(backup.provider);
654
+ const wrapped = new RetryProvider(primary.provider, {
655
+ resolveFallbackRoute: route.resolveFallbackRoute,
656
+ });
657
+ recordFallbackServed({ upstream: "openai" }, Date.now() - 11 * 60_000);
658
+
659
+ const thrown = await captureError(
660
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
661
+ );
662
+
663
+ expect(thrown).toBe(originalError);
664
+ expect(primary.calls()).toBe(1);
665
+ expect(route.calls()).toBe(1);
666
+ expect(backup.calls()).toBe(1);
667
+ });
668
+
669
+ test("resolveFallbackRoute unset → behavior unchanged (original error rethrown)", async () => {
670
+ const finalError = new ProviderError("Service Unavailable", "openai", 503);
671
+ const primary = failingProvider("openai", () => finalError);
672
+ const wrapped = new RetryProvider(primary.provider);
673
+
674
+ const thrown = await captureError(
675
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
676
+ );
677
+
678
+ expect(thrown).toBe(finalError);
679
+ expect(primary.calls()).toBe(1 + DEFAULT_MAX_RETRIES);
680
+ });
681
+
682
+ test("explicit max_tokens/effort/thinking are cleared so the backup profile's values win", async () => {
683
+ const primary = failingProvider(
684
+ "openai",
685
+ () => new ProviderError("model not found", "openai", 404),
686
+ );
687
+ const backup = backupProvider();
688
+ const route = makeRoute(backup.provider);
689
+ const wrapped = new RetryProvider(primary.provider, {
690
+ resolveFallbackRoute: route.resolveFallbackRoute,
691
+ });
692
+
693
+ await wrapped.sendMessage(MESSAGES, {
694
+ config: {
695
+ callSite: "mainAgent",
696
+ max_tokens: 42,
697
+ effort: "low",
698
+ thinking: { type: "disabled" },
699
+ },
700
+ });
701
+
702
+ const config = backup.seenConfig();
703
+ expect(config.model).toBe("backup-model");
704
+ // The explicit per-call cap was cleared; the backup profile's resolved
705
+ // maxTokens applies instead.
706
+ expect(config.max_tokens).toBe(2222);
707
+ expect(config.effort).not.toBe("low");
708
+ });
709
+
710
+ test("route with forwardUsageAttributionHeaders false → no X-Vellum-* headers reach the backup adapter, even when the primary forwards them", async () => {
711
+ // A managed primary (forwarding enabled) falling back to a BYOK or other
712
+ // third-party adapter must not leak billing metadata: the fallback
713
+ // normalization follows the ROUTE's policy, not the primary's.
714
+ const primary = failingProvider(
715
+ "openai",
716
+ () => new ProviderError("model not found", "openai", 404),
717
+ );
718
+ const backup = backupProvider();
719
+ const route = makeRoute(backup.provider, {
720
+ forwardUsageAttributionHeaders: false,
721
+ });
722
+ const wrapped = new RetryProvider(primary.provider, {
723
+ forwardUsageAttributionHeaders: true,
724
+ resolveFallbackRoute: route.resolveFallbackRoute,
725
+ });
726
+
727
+ const result = await wrapped.sendMessage(MESSAGES, {
728
+ config: { callSite: "mainAgent" },
729
+ });
730
+
731
+ expect(result.model).toBe("backup-model");
732
+ expect(backup.seenConfig().usageAttributionHeaders).toBeUndefined();
733
+ });
734
+
735
+ test("route with forwardUsageAttributionHeaders true → headers present and reflect the backup profile, even when the primary does not forward them", async () => {
736
+ // A non-managed primary (forwarding disabled) falling back to the
737
+ // managed proxy must include the required attribution headers.
738
+ const primary = failingProvider(
739
+ "openai",
740
+ () => new ProviderError("model not found", "openai", 404),
741
+ );
742
+ const backup = backupProvider();
743
+ const route = makeRoute(backup.provider, {
744
+ forwardUsageAttributionHeaders: true,
745
+ });
746
+ const wrapped = new RetryProvider(primary.provider, {
747
+ forwardUsageAttributionHeaders: false,
748
+ resolveFallbackRoute: route.resolveFallbackRoute,
749
+ });
750
+
751
+ const result = await wrapped.sendMessage(MESSAGES, {
752
+ config: { callSite: "mainAgent" },
753
+ });
754
+
755
+ expect(result.model).toBe("backup-model");
756
+ const headers = backup.seenConfig().usageAttributionHeaders as Record<
757
+ string,
758
+ string
759
+ >;
760
+ expect(headers["X-Vellum-Inference-Profile"]).toBe("backup-profile");
761
+ expect(headers["X-Vellum-Resolved-Model"]).toBe("backup-model");
762
+ expect(headers["X-Vellum-Resolved-Provider"]).toBe("anthropic");
763
+ });
764
+
765
+ test("fallback response without actualProvider → stamped with the backup provider's name", async () => {
766
+ // The outer call-site router attributes success by `actualProvider`;
767
+ // without the stamp it would record the fallback under the failed
768
+ // primary provider and apply the wrong pricing.
769
+ const primary = failingProvider(
770
+ "openai",
771
+ () => new ProviderError("model not found", "openai", 404),
772
+ );
773
+ const backup = backupProvider();
774
+ const route = makeRoute(backup.provider);
775
+ const wrapped = new RetryProvider(primary.provider, {
776
+ resolveFallbackRoute: route.resolveFallbackRoute,
777
+ });
778
+
779
+ const result = await wrapped.sendMessage(MESSAGES, {
780
+ config: { callSite: "mainAgent" },
781
+ });
782
+
783
+ expect(result.actualProvider).toBe("anthropic");
784
+ });
785
+
786
+ test("fallback response with adapter-set actualProvider → preserved, not overwritten", async () => {
787
+ const primary = failingProvider(
788
+ "openai",
789
+ () => new ProviderError("model not found", "openai", 404),
790
+ );
791
+ const backup: Provider = {
792
+ name: "anthropic",
793
+ sendMessage: async () => ({
794
+ ...okResponse("backup-model"),
795
+ actualProvider: "openrouter/anthropic",
796
+ }),
797
+ };
798
+ const route = makeRoute(backup);
799
+ const wrapped = new RetryProvider(primary.provider, {
800
+ resolveFallbackRoute: route.resolveFallbackRoute,
801
+ });
802
+
803
+ const result = await wrapped.sendMessage(MESSAGES, {
804
+ config: { callSite: "mainAgent" },
805
+ });
806
+
807
+ expect(result.actualProvider).toBe("openrouter/anthropic");
808
+ });
809
+
810
+ test("fallback response → stamped with the backup profile key so usage tracking attributes it correctly", async () => {
811
+ // The outer UsageTrackingProvider resolves `inferenceProfile` from the
812
+ // ORIGINAL request options, which still carry the failed primary's
813
+ // resolution. The stamp is what lets the usage event bill the fallback
814
+ // serve under the backup profile.
815
+ const primary = failingProvider(
816
+ "openai",
817
+ () => new ProviderError("model not found", "openai", 404),
818
+ );
819
+ const backup = backupProvider();
820
+ const route = makeRoute(backup.provider);
821
+ const wrapped = new RetryProvider(primary.provider, {
822
+ resolveFallbackRoute: route.resolveFallbackRoute,
823
+ });
824
+
825
+ const result = await wrapped.sendMessage(MESSAGES, {
826
+ config: { callSite: "mainAgent" },
827
+ });
828
+
829
+ expect(result.actualInferenceProfile).toBe("backup-profile");
830
+ });
831
+
832
+ test("fallback response with a wrapper-set actualInferenceProfile → preserved, not overwritten", async () => {
833
+ const primary = failingProvider(
834
+ "openai",
835
+ () => new ProviderError("model not found", "openai", 404),
836
+ );
837
+ const backup: Provider = {
838
+ name: "anthropic",
839
+ sendMessage: async () => ({
840
+ ...okResponse("backup-model"),
841
+ actualInferenceProfile: "inner-specific-profile",
842
+ }),
843
+ };
844
+ const route = makeRoute(backup);
845
+ const wrapped = new RetryProvider(primary.provider, {
846
+ resolveFallbackRoute: route.resolveFallbackRoute,
847
+ });
848
+
849
+ const result = await wrapped.sendMessage(MESSAGES, {
850
+ config: { callSite: "mainAgent" },
851
+ });
852
+
853
+ expect(result.actualInferenceProfile).toBe("inner-specific-profile");
854
+ });
855
+
856
+ test("native web search sentinel is dropped when the backup cannot serve it", async () => {
857
+ // The caller appended the sentinel from the PRIMARY route's capability, so
858
+ // a backup without server-side search would answer with a tool call
859
+ // nothing can execute. Degraded mode loses native search, not the turn.
860
+ const primary = failingProvider(
861
+ "openai",
862
+ () => new ProviderError("model not found", "openai", 404),
863
+ );
864
+ const backup = backupProvider("gemini", { supportsNativeWebSearch: false });
865
+ const route = makeRoute(backup.provider);
866
+ const wrapped = new RetryProvider(primary.provider, {
867
+ resolveFallbackRoute: route.resolveFallbackRoute,
868
+ });
869
+
870
+ await wrapped.sendMessage(MESSAGES, {
871
+ config: { callSite: "mainAgent", nativeWebSearchSentinel: true },
872
+ tools: [
873
+ {
874
+ name: "read_file",
875
+ description: "d",
876
+ input_schema: { type: "object" },
877
+ },
878
+ {
879
+ name: "web_search",
880
+ description: "d",
881
+ input_schema: { type: "object" },
882
+ },
883
+ ],
884
+ });
885
+
886
+ expect(backup.calls()).toBe(1);
887
+ expect(backup.seenToolNames()).toEqual(["read_file"]);
888
+ });
889
+
890
+ test("native web search sentinel survives when the backup serves it too", async () => {
891
+ const primary = failingProvider(
892
+ "openai",
893
+ () => new ProviderError("model not found", "openai", 404),
894
+ );
895
+ const backup = backupProvider("anthropic", {
896
+ supportsNativeWebSearch: true,
897
+ });
898
+ const route = makeRoute(backup.provider);
899
+ const wrapped = new RetryProvider(primary.provider, {
900
+ resolveFallbackRoute: route.resolveFallbackRoute,
901
+ });
902
+
903
+ await wrapped.sendMessage(MESSAGES, {
904
+ config: { callSite: "mainAgent", nativeWebSearchSentinel: true },
905
+ tools: [
906
+ {
907
+ name: "read_file",
908
+ description: "d",
909
+ input_schema: { type: "object" },
910
+ },
911
+ {
912
+ name: "web_search",
913
+ description: "d",
914
+ input_schema: { type: "object" },
915
+ },
916
+ ],
917
+ });
918
+
919
+ expect(backup.seenToolNames()).toEqual(["read_file", "web_search"]);
920
+ });
921
+
922
+ test("an app-executed web_search tool survives the fallback untouched", async () => {
923
+ // With a search backend such as Brave or the platform search proxy, the
924
+ // daemon executes `web_search` itself and the caller sets no sentinel
925
+ // marker. Filtering it by name would strip a capability the backup can
926
+ // still serve, so the tool list has to carry over unchanged.
927
+ const primary = failingProvider(
928
+ "openai",
929
+ () => new ProviderError("model not found", "openai", 404),
930
+ );
931
+ const backup = backupProvider("gemini", { supportsNativeWebSearch: false });
932
+ const route = makeRoute(backup.provider);
933
+ const wrapped = new RetryProvider(primary.provider, {
934
+ resolveFallbackRoute: route.resolveFallbackRoute,
935
+ });
936
+
937
+ await wrapped.sendMessage(MESSAGES, {
938
+ config: { callSite: "mainAgent" },
939
+ tools: [
940
+ {
941
+ name: "read_file",
942
+ description: "d",
943
+ input_schema: { type: "object" },
944
+ },
945
+ {
946
+ name: "web_search",
947
+ description: "d",
948
+ input_schema: { type: "object" },
949
+ },
950
+ ],
951
+ });
952
+
953
+ expect(backup.seenToolNames()).toEqual(["read_file", "web_search"]);
954
+ });
955
+
956
+ test("a tool-less call site loses the tool_choice paired with the sentinel", async () => {
957
+ // `AgentLoop` sets `tool_choice: { type: "auto" }` under the same condition
958
+ // that appends the sentinel, so a call site with no tools of its own sends
959
+ // the sentinel as its ONLY tool. Filtering it and leaving the tool_choice
960
+ // behind puts a choice with nothing to choose from on the wire, which
961
+ // Anthropic rejects outright: a recoverable outage would become a hard 400
962
+ // on the backup.
963
+ const primary = failingProvider(
964
+ "openai",
965
+ () => new ProviderError("model not found", "openai", 404),
966
+ );
967
+ const backup = backupProvider("gemini", { supportsNativeWebSearch: false });
968
+ const route = makeRoute(backup.provider);
969
+ const wrapped = new RetryProvider(primary.provider, {
970
+ resolveFallbackRoute: route.resolveFallbackRoute,
971
+ });
972
+
973
+ await wrapped.sendMessage(MESSAGES, {
974
+ config: {
975
+ callSite: "mainAgent",
976
+ nativeWebSearchSentinel: true,
977
+ tool_choice: { type: "auto" },
978
+ },
979
+ tools: [
980
+ {
981
+ name: "web_search",
982
+ description: "d",
983
+ input_schema: { type: "object" },
984
+ },
985
+ ],
986
+ });
987
+
988
+ expect(backup.calls()).toBe(1);
989
+ expect(backup.seenToolNames()).toEqual([]);
990
+ expect(backup.seenConfig().tool_choice).toBeUndefined();
991
+ });
992
+
993
+ test("a tool_choice survives the fallback whenever a tool is left to choose", async () => {
994
+ // The rule is deliberately narrow. A conversation-level `toolChoice` takes
995
+ // precedence over the sentinel's `auto` in `AgentLoop`, so it is caller
996
+ // intent a route change must not quietly discard, and the request it
997
+ // produces is valid on every wire.
998
+ const primary = failingProvider(
999
+ "openai",
1000
+ () => new ProviderError("model not found", "openai", 404),
1001
+ );
1002
+ const backup = backupProvider("gemini", { supportsNativeWebSearch: false });
1003
+ const route = makeRoute(backup.provider);
1004
+ const wrapped = new RetryProvider(primary.provider, {
1005
+ resolveFallbackRoute: route.resolveFallbackRoute,
1006
+ });
1007
+
1008
+ await wrapped.sendMessage(MESSAGES, {
1009
+ config: {
1010
+ callSite: "mainAgent",
1011
+ nativeWebSearchSentinel: true,
1012
+ tool_choice: { type: "tool", name: "read_file" },
1013
+ },
1014
+ tools: [
1015
+ {
1016
+ name: "read_file",
1017
+ description: "d",
1018
+ input_schema: { type: "object" },
1019
+ },
1020
+ {
1021
+ name: "web_search",
1022
+ description: "d",
1023
+ input_schema: { type: "object" },
1024
+ },
1025
+ ],
1026
+ });
1027
+
1028
+ expect(backup.seenToolNames()).toEqual(["read_file"]);
1029
+ expect(backup.seenConfig().tool_choice).toEqual({
1030
+ type: "tool",
1031
+ name: "read_file",
1032
+ });
1033
+ });
1034
+
1035
+ test("a tool_choice survives when the sentinel was never filtered", async () => {
1036
+ // A backup that serves native search keeps the whole list, so nothing about
1037
+ // the caller's config changes either.
1038
+ const primary = failingProvider(
1039
+ "openai",
1040
+ () => new ProviderError("model not found", "openai", 404),
1041
+ );
1042
+ const backup = backupProvider("anthropic", {
1043
+ supportsNativeWebSearch: true,
1044
+ });
1045
+ const route = makeRoute(backup.provider);
1046
+ const wrapped = new RetryProvider(primary.provider, {
1047
+ resolveFallbackRoute: route.resolveFallbackRoute,
1048
+ });
1049
+
1050
+ await wrapped.sendMessage(MESSAGES, {
1051
+ config: {
1052
+ callSite: "mainAgent",
1053
+ nativeWebSearchSentinel: true,
1054
+ tool_choice: { type: "auto" },
1055
+ },
1056
+ tools: [
1057
+ {
1058
+ name: "web_search",
1059
+ description: "d",
1060
+ input_schema: { type: "object" },
1061
+ },
1062
+ ],
1063
+ });
1064
+
1065
+ expect(backup.seenToolNames()).toEqual(["web_search"]);
1066
+ expect(backup.seenConfig().tool_choice).toEqual({ type: "auto" });
1067
+ });
1068
+
1069
+ test("the sentinel marker never reaches the provider wire config", async () => {
1070
+ const primary = failingProvider(
1071
+ "openai",
1072
+ () => new ProviderError("model not found", "openai", 404),
1073
+ );
1074
+ const backup = backupProvider("gemini", { supportsNativeWebSearch: false });
1075
+ const route = makeRoute(backup.provider);
1076
+ const wrapped = new RetryProvider(primary.provider, {
1077
+ resolveFallbackRoute: route.resolveFallbackRoute,
1078
+ });
1079
+
1080
+ await wrapped.sendMessage(MESSAGES, {
1081
+ config: { callSite: "mainAgent", nativeWebSearchSentinel: true },
1082
+ tools: [
1083
+ {
1084
+ name: "web_search",
1085
+ description: "d",
1086
+ input_schema: { type: "object" },
1087
+ },
1088
+ ],
1089
+ });
1090
+
1091
+ expect(backup.seenConfig().nativeWebSearchSentinel).toBeUndefined();
1092
+ });
1093
+ });