@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
@@ -37,7 +37,7 @@ import { z } from "zod";
37
37
  import { usesConceptPageMemory } from "../../../../config/memory-v3-gate.js";
38
38
  import type { AssistantConfig } from "../../../../config/types.js";
39
39
  import { warmGuardianBindings } from "../../../../contacts/guardian-delivery-reader.js";
40
- import { emitNotificationSignal } from "../../../../notifications/emit-signal.js";
40
+ import { emitBackgroundFailureSignal } from "../../../../notifications/background-failure-signal.js";
41
41
  import { getDb } from "../../../../persistence/db-connection.js";
42
42
  import type { MemoryJob } from "../../../../persistence/jobs-store.js";
43
43
  import {
@@ -199,53 +199,20 @@ export async function memoryV2SweepJob(
199
199
  } catch (err) {
200
200
  const errorMessage = err instanceof Error ? err.message : String(err);
201
201
  log.error({ err }, "memory v2 sweep failed");
202
- emitSweepActivityFailed({
203
- jobId: job.id,
202
+ // The sweep is a data job with no LLM turn to classify, so its failures
203
+ // report as generic exceptions through the shared background-failure
204
+ // seam, which owns the payload shape and dedupe key.
205
+ emitBackgroundFailureSignal({
206
+ jobName: JOB_NAME,
207
+ sourceChannel: "scheduler",
208
+ sourceContextId: job.id,
209
+ errorKind: "exception",
204
210
  errorMessage,
205
211
  });
206
212
  return 0;
207
213
  }
208
214
  }
209
215
 
210
- /**
211
- * Emit an `activity.failed` notification for a failed sweep run. Mirrors
212
- * the shape `runBackgroundJob` produces for its own failures so the home
213
- * feed and native notifications stay consistent regardless of which code
214
- * path executed the work. Fire-and-forget — a notification failure must
215
- * never break sweep operation.
216
- */
217
- function emitSweepActivityFailed(args: {
218
- jobId: string;
219
- errorMessage: string;
220
- }): void {
221
- const day = new Date().toISOString().slice(0, 10);
222
- emitNotificationSignal({
223
- sourceChannel: "scheduler",
224
- sourceContextId: args.jobId,
225
- sourceEventName: "activity.failed",
226
- dedupeKey: `activity-failed:${JOB_NAME}:${day}`,
227
- contextPayload: {
228
- jobName: JOB_NAME,
229
- errorMessage: args.errorMessage,
230
- errorKind: "exception",
231
- },
232
- attentionHints: {
233
- requiresAction: false,
234
- urgency: "medium",
235
- isAsyncBackground: true,
236
- visibleInSourceNow: false,
237
- },
238
- }).catch((emitErr) => {
239
- log.warn(
240
- {
241
- err: emitErr instanceof Error ? emitErr.message : String(emitErr),
242
- jobId: args.jobId,
243
- },
244
- "Failed to emit activity.failed notification for memory v2 sweep",
245
- );
246
- });
247
- }
248
-
249
216
  /**
250
217
  * Append each non-empty entry to `memory/buffer.md` + today's archive,
251
218
  * using the same format `remember()` produces. Returns the count of entries
@@ -164,6 +164,7 @@ export class LocalRerankBackend {
164
164
  modelCacheDir,
165
165
  this.dtype,
166
166
  ],
167
+ windowsHide: true,
167
168
  env: { ...workerMemoryEnv(), ...workerComputeEnv() },
168
169
  stdin: "pipe",
169
170
  stdout: "pipe",
@@ -15,6 +15,7 @@ import { writeFileSync } from "node:fs";
15
15
  import { getConfig } from "../../../config/loader.js";
16
16
  import { isMemoryEnabled } from "../../../config/memory-v3-gate.js";
17
17
  import { rehydratePlatformCredentials } from "../../../config/platform-rehydration.js";
18
+ import { startConversationEvictor } from "../../../daemon/conversation-evictor.js";
18
19
  import { resetDb } from "../../../persistence/db-connection.js";
19
20
  import {
20
21
  EMBEDDING_SHUTDOWN_BUDGET_MS,
@@ -84,6 +85,12 @@ async function main(): Promise<void> {
84
85
  );
85
86
  }
86
87
 
88
+ // Sweep idle conversations out of the in-memory pool. The daemon starts
89
+ // this at startup; this process hosts conversations too (retrospective and
90
+ // consolidation passes), so without it every pass's conversation is
91
+ // retained for the process lifetime.
92
+ startConversationEvictor();
93
+
87
94
  let worker: ReturnType<typeof startMemoryJobsWorkerLoop> | null = null;
88
95
  let keepAlive: ReturnType<typeof setInterval> | null = null;
89
96
  let disposePidGuard: (() => void) | null = null;
@@ -38,6 +38,7 @@ describe("API_KEY_PROVIDERS", () => {
38
38
  expect(API_KEY_PROVIDERS).toContain("anthropic");
39
39
  expect(API_KEY_PROVIDERS).toContain("openai");
40
40
  expect(API_KEY_PROVIDERS).toContain("gemini");
41
+ expect(API_KEY_PROVIDERS).not.toContain("vellum");
41
42
  });
42
43
 
43
44
  test("includes search providers", () => {
@@ -120,6 +120,61 @@ describe("RetryProvider — callSite resolution", () => {
120
120
  expect(config.modelIntent).toBeUndefined();
121
121
  });
122
122
 
123
+ test("stamps OpenCode support headers and strips conversationId", async () => {
124
+ setLlmConfig({
125
+ callSites: {
126
+ memoryRetrieval: {
127
+ provider: "opencode",
128
+ model: "mimo-v2.5-free",
129
+ },
130
+ },
131
+ });
132
+
133
+ let seen: SendMessageOptions | undefined;
134
+ const wrapped = new RetryProvider(
135
+ makeProvider("opencode", (options) => {
136
+ seen = options;
137
+ }),
138
+ );
139
+
140
+ await wrapped.sendMessage(DUMMY_MESSAGES, {
141
+ config: { callSite: "memoryRetrieval", conversationId: "conv-xyz" },
142
+ });
143
+
144
+ const config = seen?.config as Record<string, unknown>;
145
+ const headers = config.requestHeaders as Record<string, string>;
146
+ expect(config.conversationId).toBeUndefined();
147
+ expect(headers["x-opencode-session"]).toBe("conv-xyz");
148
+ expect(headers["x-opencode-request"]).toBeDefined();
149
+ expect(headers).not.toHaveProperty("session_id");
150
+ });
151
+
152
+ test("does not stamp OpenCode headers on openai-compatible", async () => {
153
+ setLlmConfig({
154
+ callSites: {
155
+ memoryRetrieval: {
156
+ provider: "openai-compatible",
157
+ model: "local-model",
158
+ },
159
+ },
160
+ });
161
+
162
+ let seen: SendMessageOptions | undefined;
163
+ const wrapped = new RetryProvider(
164
+ makeProvider("openai-compatible", (options) => {
165
+ seen = options;
166
+ }),
167
+ );
168
+
169
+ await wrapped.sendMessage(DUMMY_MESSAGES, {
170
+ config: { callSite: "memoryRetrieval", conversationId: "conv-xyz" },
171
+ });
172
+
173
+ const config = seen?.config as Record<string, unknown>;
174
+ expect(config.requestHeaders).toBeUndefined();
175
+ expect(config.conversationId).toBeUndefined();
176
+ });
177
+
123
178
  test("strips conversationId before delegating to the inner provider", async () => {
124
179
  setLlmConfig({
125
180
  callSites: {
@@ -52,9 +52,10 @@ describe("vellum connection routing", () => {
52
52
  expect(isVellumManagedConnection({ provider: "vellum" })).toBe(true);
53
53
  });
54
54
 
55
- test("the vellum sentinel is not a real provider without an override", () => {
56
- // No `provider` override → effective provider is the `vellum` sentinel,
57
- // which has no catalog entry / adapter → null.
55
+ test("the vellum sentinel does not dispatch without an override", () => {
56
+ // No `provider` override → the connection column is the routing
57
+ // sentinel and must not build an adapter, even though Vellum-hosted
58
+ // GPU models share this catalog id.
58
59
  const adapter = createAdapterFromConnection(
59
60
  vellumConnection,
60
61
  resolvedAuth,
@@ -76,6 +77,23 @@ describe("vellum connection routing", () => {
76
77
  );
77
78
  expect(adapter).not.toBeNull();
78
79
  });
80
+
81
+ test("provider override vellum routes GPU models through VellumProvider", () => {
82
+ const adapter = createAdapterFromConnection(
83
+ vellumConnection,
84
+ {
85
+ kind: "header",
86
+ headers: { Authorization: "Bearer test-key" },
87
+ baseUrl: "https://platform.example/v1/runtime-proxy/vellum",
88
+ },
89
+ {
90
+ model: "qwen/qwen3-8b",
91
+ provider: "vellum",
92
+ },
93
+ );
94
+ expect(adapter).not.toBeNull();
95
+ expect(adapter?.name).toBe("vellum");
96
+ });
79
97
  });
80
98
 
81
99
  describe("effectiveConnectionAuth", () => {
@@ -52,6 +52,7 @@ import {
52
52
  } from "./inference/connections.js";
53
53
  import { resolveManagedProxyContext } from "./platform-proxy/context.js";
54
54
  import { checkCredentialPresence } from "./provider-availability.js";
55
+ import { dispatchProviderResolvable } from "./provider-resolvability.js";
55
56
  import type { ProvidersConfig } from "./registry.js";
56
57
  import { resolveProviderFromConnection } from "./registry.js";
57
58
  import {
@@ -67,6 +68,7 @@ import {
67
68
  } from "./vellum-model-routing.js";
68
69
 
69
70
  export { ConnectionResolutionError, resolveRoutingIdentity };
71
+ export { dispatchProviderResolvable } from "./provider-resolvability.js";
70
72
 
71
73
  const log = getLogger("providers/connection-resolution");
72
74
 
@@ -118,23 +120,6 @@ export function writableProfileProviderIssue(provider: string): string | null {
118
120
  return `Invalid provider "${provider}". Use a known provider or the name of an existing connection.`;
119
121
  }
120
122
 
121
- /**
122
- * Selection-time predicate for `ResolveCallSiteOpts.isResolvableProvider`:
123
- * a provider value dispatches when it is a known vendor/identity or names a
124
- * connection entry row. Permissive on DB unavailability so a transient blip
125
- * never heals away a valid entry profile; dispatch soft-fails on its own.
126
- */
127
- export function dispatchProviderResolvable(provider: string): boolean {
128
- if (VALID_CONNECTION_PROVIDERS.includes(provider)) {
129
- return true;
130
- }
131
- try {
132
- return getConnection(getDb(), provider) != null;
133
- } catch {
134
- return true;
135
- }
136
- }
137
-
138
123
  /**
139
124
  * The vendor a resolved provider value expects its connection row to serve:
140
125
  * catalog ids and routing identities pass through, and an entry-name label
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Circuit breaker for managed fallback routes.
3
+ *
4
+ * Once a backup profile has served a request, the primary route is known to be
5
+ * down. Paying the full retry budget on every later request only adds latency
6
+ * to an answer the backup was always going to give, so the breaker remembers
7
+ * the outage and sends the next requests straight to the backup. Recovery is
8
+ * request-driven: after a cooldown, exactly one request probes the primary. A
9
+ * successful probe closes the breaker; a failed one re-trips it with a longer
10
+ * cooldown.
11
+ *
12
+ * Scope
13
+ * -----
14
+ * A remembered outage is keyed by the route it was actually observed on, and
15
+ * the two failure shapes this feature exists for indict different things:
16
+ *
17
+ * - An upstream outage (5xx, 429, transport failure, a broken managed
18
+ * credential) indicts the whole provider, so it is remembered under the
19
+ * upstream name and every model on it skips the primary.
20
+ * - A retired or renamed model (404, `model_not_found`, the managed proxy's
21
+ * preflight 400) indicts one model. Remembering it provider-wide would
22
+ * divert healthy profiles on the same upstream to their backups for the
23
+ * whole cooldown, and would let a probe of some other healthy model close a
24
+ * breaker the retired model still deserves. It is remembered under the
25
+ * upstream plus that model instead.
26
+ *
27
+ * Reads always name the full route, so a request is skipped when either its
28
+ * upstream or its exact model is remembered as down.
29
+ *
30
+ * The cooldown carries plus or minus 20 percent of jitter so a fleet of daemons
31
+ * coming out of the same provider outage does not probe a recovering upstream
32
+ * in the same second.
33
+ *
34
+ * State is intentionally process-local and never persisted: the daemon is a
35
+ * single process, and a restart resets every route to its primary. The worst
36
+ * case for that choice is one request rediscovering an ongoing outage the
37
+ * expensive way, which is exactly what the very first request of an outage
38
+ * already pays.
39
+ */
40
+
41
+ import { getLogger } from "../util/logger.js";
42
+
43
+ const log = getLogger("fallback-breaker");
44
+
45
+ /** Eligible failures inside this window count toward the trip threshold. */
46
+ const FAILURE_WINDOW_MS = 5 * 60_000;
47
+
48
+ /** Eligible failures needed to trip when no backup has served yet. */
49
+ const FAILURES_TO_TRIP = 2;
50
+
51
+ /** Cooldown before the first recovery probe is admitted. */
52
+ const BASE_COOLDOWN_MS = 120_000;
53
+
54
+ /** Ceiling for the doubling cooldown, so a long outage still gets probed. */
55
+ const MAX_COOLDOWN_MS = 10 * 60_000;
56
+
57
+ /** Fraction of the cooldown spent on desynchronizing jitter. */
58
+ const COOLDOWN_JITTER = 0.2;
59
+
60
+ /**
61
+ * A route the breaker can remember. `model` narrows the entry to a single
62
+ * model: writes that omit it record an upstream-wide outage, and reads that
63
+ * include it consult both the upstream entry and the model's own.
64
+ */
65
+ export interface BreakerRoute {
66
+ upstream: string;
67
+ model?: string;
68
+ }
69
+
70
+ /**
71
+ * Identifies the breaker state that observed a primary failure. A fallback
72
+ * completion may arrive after a recovery probe, so it must only update the
73
+ * state that was current when the primary failed.
74
+ */
75
+ export interface BreakerObservation {
76
+ key: string;
77
+ generation: number;
78
+ }
79
+
80
+ interface BreakerState {
81
+ /** Timestamps of recent eligible failures, pruned to the failure window. */
82
+ failures: number[];
83
+ /** When the current cooldown expires, or null while the breaker is closed. */
84
+ openUntil: number | null;
85
+ /** Consecutive failed recovery probes; each one doubles the cooldown. */
86
+ failedProbes: number;
87
+ /** Whether a probe is currently deciding recovery for this entry. */
88
+ probeInFlight: boolean;
89
+ /** Monotonic identity for this state generation. */
90
+ generation: number;
91
+ }
92
+
93
+ const breakers = new Map<string, BreakerState>();
94
+ let nextGeneration = 0;
95
+
96
+ /**
97
+ * The single entry a write targets. A model-scoped key can never collide with
98
+ * an upstream name: the separator is a space, which no provider id contains.
99
+ */
100
+ function keyOf(route: BreakerRoute): string {
101
+ return route.model === undefined
102
+ ? route.upstream
103
+ : `${route.upstream} ${route.model}`;
104
+ }
105
+
106
+ /** Every entry that governs this route, upstream-wide first. */
107
+ function coveringKeys(route: BreakerRoute): string[] {
108
+ return route.model === undefined
109
+ ? [route.upstream]
110
+ : [route.upstream, keyOf(route)];
111
+ }
112
+
113
+ function stateFor(key: string): BreakerState {
114
+ const existing = breakers.get(key);
115
+ if (existing !== undefined) {
116
+ return existing;
117
+ }
118
+ const created: BreakerState = {
119
+ failures: [],
120
+ openUntil: null,
121
+ failedProbes: 0,
122
+ probeInFlight: false,
123
+ generation: ++nextGeneration,
124
+ };
125
+ breakers.set(key, created);
126
+ return created;
127
+ }
128
+
129
+ /**
130
+ * Cooldown for the next open period. Doubles per consecutive failed probe and
131
+ * is capped at {@link MAX_COOLDOWN_MS} both before and after jitter, so the
132
+ * ceiling holds for the value actually used.
133
+ */
134
+ function cooldownMs(failedProbes: number): number {
135
+ const base = Math.min(BASE_COOLDOWN_MS * 2 ** failedProbes, MAX_COOLDOWN_MS);
136
+ const jittered = base * (1 + (Math.random() * 2 - 1) * COOLDOWN_JITTER);
137
+ return Math.min(Math.round(jittered), MAX_COOLDOWN_MS);
138
+ }
139
+
140
+ function trip(
141
+ key: string,
142
+ state: BreakerState,
143
+ now: number,
144
+ reason: string,
145
+ ): void {
146
+ const cooldown = cooldownMs(state.failedProbes);
147
+ state.openUntil = now + cooldown;
148
+ state.failures = [];
149
+ log.info(
150
+ {
151
+ route: key,
152
+ cooldownMs: cooldown,
153
+ failedProbes: state.failedProbes,
154
+ reason,
155
+ },
156
+ "Fallback breaker tripped; routing to the backup profile until the cooldown expires",
157
+ );
158
+ }
159
+
160
+ /** Whether the entry is open, whatever the cooldown says. */
161
+ function isOpen(
162
+ state: BreakerState | undefined,
163
+ ): state is BreakerState & { openUntil: number } {
164
+ return state !== undefined && state.openUntil !== null;
165
+ }
166
+
167
+ /** Open entries governing this route, paired with their keys. */
168
+ function openCovering(
169
+ route: BreakerRoute,
170
+ ): { key: string; state: BreakerState & { openUntil: number } }[] {
171
+ const open: { key: string; state: BreakerState & { openUntil: number } }[] =
172
+ [];
173
+ for (const key of coveringKeys(route)) {
174
+ const state = breakers.get(key);
175
+ if (isOpen(state)) {
176
+ open.push({ key, state });
177
+ }
178
+ }
179
+ return open;
180
+ }
181
+
182
+ /**
183
+ * Record an outage-shaped failure of the primary route. Two of them inside
184
+ * {@link FAILURE_WINDOW_MS} trip the breaker; a single blip does not. A failure
185
+ * recorded while the entry is already open changes nothing, since only a
186
+ * recovery probe decides when to close it.
187
+ *
188
+ * The caller decides the scope by what it names: pass the upstream alone for
189
+ * an outage, upstream plus model for a failure that indicts one model.
190
+ */
191
+ export function recordPrimaryFailure(
192
+ route: BreakerRoute,
193
+ now: number = Date.now(),
194
+ ): BreakerObservation {
195
+ const key = keyOf(route);
196
+ const state = stateFor(key);
197
+ if (isOpen(state)) {
198
+ return { key, generation: state.generation };
199
+ }
200
+ state.failures = state.failures.filter((at) => now - at < FAILURE_WINDOW_MS);
201
+ state.failures.push(now);
202
+ if (state.failures.length >= FAILURES_TO_TRIP) {
203
+ trip(key, state, now, "eligible_failures");
204
+ }
205
+ return { key, generation: state.generation };
206
+ }
207
+
208
+ /**
209
+ * Record that a backup profile actually served a request the primary could
210
+ * not. This trips the breaker immediately: unlike a bare failure, a completed
211
+ * backup serve proves both that the primary is down and that the backup can
212
+ * carry the traffic, so there is nothing left to learn from the next request
213
+ * repeating the same discovery. Scoped by what the caller names, as above.
214
+ */
215
+ export function recordFallbackServed(
216
+ route: BreakerRoute,
217
+ now: number = Date.now(),
218
+ observation?: BreakerObservation,
219
+ ): void {
220
+ const key = keyOf(route);
221
+ const state = observation === undefined ? stateFor(key) : breakers.get(key);
222
+ if (
223
+ observation !== undefined &&
224
+ (state === undefined ||
225
+ observation.key !== key ||
226
+ observation.generation !== state.generation)
227
+ ) {
228
+ log.info(
229
+ { route: key, observation, currentGeneration: state?.generation },
230
+ "Ignoring a stale fallback serve for the fallback breaker",
231
+ );
232
+ return;
233
+ }
234
+ if (state === undefined || isOpen(state)) {
235
+ return;
236
+ }
237
+ trip(key, state, now, "fallback_served");
238
+ }
239
+
240
+ /**
241
+ * Record a successful primary send, clearing any failure history for the route
242
+ * that served it. Both the upstream entry and this model's own entry are
243
+ * cleared: the upstream answered, and it answered for this model. Another
244
+ * model's remembered outage on the same upstream is left alone.
245
+ */
246
+ export function recordPrimarySuccess(
247
+ route: BreakerRoute,
248
+ now: number = Date.now(),
249
+ ): void {
250
+ for (const key of coveringKeys(route)) {
251
+ const state = breakers.get(key);
252
+ if (state === undefined) {
253
+ continue;
254
+ }
255
+ if (isOpen(state)) {
256
+ log.info(
257
+ { route: key, openForMs: Math.max(0, state.openUntil - now) },
258
+ "Primary route served a request; closing the fallback breaker",
259
+ );
260
+ }
261
+ breakers.delete(key);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Whether this request should skip the primary route and go straight to the
267
+ * backup. True while any entry governing the route is inside its cooldown, and
268
+ * also while a probe is in flight, so concurrent requests keep using the backup
269
+ * instead of piling onto a route a single probe is still testing.
270
+ */
271
+ export function shouldSkipPrimary(
272
+ route: BreakerRoute,
273
+ now: number = Date.now(),
274
+ ): boolean {
275
+ return openCovering(route).some(
276
+ ({ state }) => state.probeInFlight || now < state.openUntil,
277
+ );
278
+ }
279
+
280
+ /**
281
+ * Claim the single recovery probe for a route whose remembered outages have all
282
+ * cooled down. Returns false for every other caller, including concurrent ones,
283
+ * which keep skipping the primary via {@link shouldSkipPrimary}.
284
+ */
285
+ export function tryAcquireRecoveryProbe(
286
+ route: BreakerRoute,
287
+ now: number = Date.now(),
288
+ ): boolean {
289
+ const open = openCovering(route);
290
+ if (open.length === 0) {
291
+ return false;
292
+ }
293
+ if (open.some(({ state }) => state.probeInFlight || now < state.openUntil)) {
294
+ return false;
295
+ }
296
+ for (const { state } of open) {
297
+ state.probeInFlight = true;
298
+ }
299
+ return true;
300
+ }
301
+
302
+ /**
303
+ * What a recovery probe learned about the route it tested.
304
+ *
305
+ * - `recovered`: the primary served the probe.
306
+ * - `failing`: it did not, and `failedRoute` names what THIS failure indicts,
307
+ * scoped the same way an initial trip is (the whole upstream for an outage,
308
+ * one model for a retirement). Null when the failure names nothing the
309
+ * breaker can remember, in which case the probed entries simply close.
310
+ * - `abandoned`: the probe never reached a verdict, because the caller
311
+ * cancelled the request. No evidence either way.
312
+ */
313
+ export type ProbeOutcome =
314
+ | { verdict: "recovered" }
315
+ | { verdict: "failing"; failedRoute: BreakerRoute | null }
316
+ | { verdict: "abandoned" };
317
+
318
+ /**
319
+ * Report the outcome of the probe claimed by {@link tryAcquireRecoveryProbe}.
320
+ *
321
+ * A probe that reached a verdict closes every entry it was testing. It is the
322
+ * freshest evidence about those entries and it supersedes whatever opened
323
+ * them: an upstream that answers a probe with a retired-model 404 is an
324
+ * upstream that answers, so continuing to divert its healthy models would be
325
+ * acting on stale evidence. What a failing probe does establish is then
326
+ * recorded anew under `failedRoute`, carrying the escalated cooldown forward,
327
+ * so a route that keeps failing the same way still doubles its wait while a
328
+ * route whose failure changed shape is remembered at its true scope.
329
+ *
330
+ * An `abandoned` probe only hands the claim back. A cancelled request never
331
+ * asked the route anything, so treating it as recovery would delete a breaker
332
+ * that nothing has retested and send the next request through the full retry
333
+ * budget of a route still known to be down. The entry keeps its deadline and
334
+ * its escalation count: the cancellation neither counts as a failed probe nor
335
+ * restarts the wait, it just leaves the probe due again.
336
+ */
337
+ export function releaseRecoveryProbe(
338
+ route: BreakerRoute,
339
+ outcome: ProbeOutcome,
340
+ now: number = Date.now(),
341
+ ): void {
342
+ if (outcome.verdict === "abandoned") {
343
+ for (const key of coveringKeys(route)) {
344
+ const state = breakers.get(key);
345
+ if (state === undefined || !state.probeInFlight) {
346
+ continue;
347
+ }
348
+ state.probeInFlight = false;
349
+ log.info(
350
+ { route: key, failedProbes: state.failedProbes },
351
+ "Recovery probe was cancelled before it reached the upstream; the fallback breaker keeps its cooldown",
352
+ );
353
+ }
354
+ return;
355
+ }
356
+ // Consecutive failed probes decide the next cooldown, so the count survives
357
+ // a change of scope: an outage that narrows to one model keeps escalating
358
+ // rather than restarting at the base wait.
359
+ let escalation = 0;
360
+ for (const key of coveringKeys(route)) {
361
+ const state = breakers.get(key);
362
+ if (state === undefined || !state.probeInFlight) {
363
+ continue;
364
+ }
365
+ state.probeInFlight = false;
366
+ escalation = Math.max(escalation, state.failedProbes);
367
+ if (outcome.verdict === "recovered") {
368
+ log.info(
369
+ { route: key, failedProbes: state.failedProbes },
370
+ "Recovery probe succeeded; closing the fallback breaker",
371
+ );
372
+ }
373
+ breakers.delete(key);
374
+ }
375
+ const failedRoute =
376
+ outcome.verdict === "recovered" ? null : outcome.failedRoute;
377
+ if (failedRoute === null) {
378
+ return;
379
+ }
380
+ const key = keyOf(failedRoute);
381
+ const state = stateFor(key);
382
+ state.failedProbes = Math.max(state.failedProbes, escalation) + 1;
383
+ trip(key, state, now, "recovery_probe_failed");
384
+ }
385
+
386
+ /** Test-only: drop every remembered route so state cannot leak between tests. */
387
+ export function resetFallbackBreaker(): void {
388
+ breakers.clear();
389
+ }