@vellumai/assistant 0.11.5 → 0.11.6-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/AGENTS.md +5 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  4. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  6. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
  7. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
  9. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
  10. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  11. package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  12. package/openapi.yaml +421 -15
  13. package/package.json +1 -1
  14. package/scripts/sync-web-search-catalog.ts +6 -0
  15. package/src/__tests__/app-pin-store.test.ts +149 -0
  16. package/src/__tests__/channel-availability-routes.test.ts +23 -1
  17. package/src/__tests__/channel-readiness-discord.test.ts +231 -0
  18. package/src/__tests__/channel-readiness-service.test.ts +126 -0
  19. package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
  20. package/src/__tests__/channel-reply-delivery.test.ts +4 -4
  21. package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
  22. package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
  23. package/src/__tests__/conversation-error.test.ts +17 -0
  24. package/src/__tests__/conversation-seed-composer.test.ts +8 -0
  25. package/src/__tests__/conversation-slash-commands.test.ts +8 -0
  26. package/src/__tests__/disk-pressure-policy.test.ts +6 -0
  27. package/src/__tests__/gemini-provider.test.ts +138 -0
  28. package/src/__tests__/history-repair.test.ts +105 -3
  29. package/src/__tests__/identity-routes.test.ts +1 -0
  30. package/src/__tests__/llm-catalog-parity.test.ts +45 -0
  31. package/src/__tests__/migration-import-from-path.test.ts +349 -0
  32. package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
  33. package/src/__tests__/oauth-commands-routes.test.ts +89 -0
  34. package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
  35. package/src/__tests__/openai-provider.test.ts +18 -0
  36. package/src/__tests__/openai-responses-provider.test.ts +18 -0
  37. package/src/__tests__/platform-callback-registration.test.ts +184 -0
  38. package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
  39. package/src/__tests__/pricing.test.ts +2 -2
  40. package/src/__tests__/public-ingress-urls.test.ts +36 -0
  41. package/src/__tests__/resolve-trust-class.test.ts +0 -48
  42. package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
  43. package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
  44. package/src/__tests__/settings-routes.test.ts +85 -3
  45. package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
  46. package/src/agent/history-repair/history-repair.ts +45 -14
  47. package/src/agent/loop.ts +4 -1
  48. package/src/api/constants/profile-config-validation.ts +60 -0
  49. package/src/api/events/tool-result.ts +6 -1
  50. package/src/api/events/watch-retro-completed.ts +52 -0
  51. package/src/api/index.ts +11 -0
  52. package/src/apps/app-pin-reconciler.ts +92 -0
  53. package/src/apps/app-pin-store.ts +125 -0
  54. package/src/channels/gateway-channel-socket-health.ts +32 -0
  55. package/src/channels/gateway-discord-admission.ts +32 -0
  56. package/src/channels/types.ts +20 -0
  57. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
  58. package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
  59. package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
  60. package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
  61. package/src/cli/commands/channels/index.ts +45 -31
  62. package/src/cli/commands/inference-profiles.ts +56 -3
  63. package/src/cli/commands/inference-providers.ts +28 -2
  64. package/src/cli/commands/oauth/index.help.ts +7 -1
  65. package/src/cli/commands/oauth/request.test.ts +290 -0
  66. package/src/cli/commands/oauth/request.ts +57 -41
  67. package/src/cli/lib/bundled-marketplace.json +14 -1
  68. package/src/cli/lib/open-browser.test.ts +67 -0
  69. package/src/cli/lib/open-browser.ts +24 -5
  70. package/src/config/__tests__/profile-materialization.test.ts +26 -0
  71. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
  72. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  73. package/src/config/feature-flag-registry.json +17 -1
  74. package/src/config/profile-materialization.ts +29 -0
  75. package/src/config/sanitize-for-transfer.ts +16 -0
  76. package/src/config/schemas/llm.ts +7 -0
  77. package/src/config/schemas/services.ts +6 -0
  78. package/src/context/outbound-sanitize.ts +6 -0
  79. package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
  80. package/src/daemon/conversation-error.ts +24 -2
  81. package/src/daemon/conversation-slash.ts +6 -15
  82. package/src/daemon/daemon-control.ts +1 -0
  83. package/src/daemon/disk-pressure-policy.ts +7 -1
  84. package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
  85. package/src/daemon/handlers/config-ingress.ts +115 -5
  86. package/src/daemon/lifecycle.ts +26 -0
  87. package/src/daemon/message-types/web-activity.ts +3 -2
  88. package/src/daemon/trust-context.ts +0 -37
  89. package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
  90. package/src/inbound/platform-callback-registration.ts +28 -2
  91. package/src/inbound/public-ingress-urls.ts +12 -0
  92. package/src/inbound/tunnel-probe.ts +261 -0
  93. package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
  94. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
  95. package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
  97. package/src/live-voice/live-voice-connection.ts +46 -8
  98. package/src/live-voice/live-voice-manager.ts +25 -0
  99. package/src/live-voice/live-voice-session-manager.ts +318 -2
  100. package/src/live-voice/live-voice-session.ts +52 -2
  101. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
  102. package/src/messaging/providers/channel-transport.ts +64 -47
  103. package/src/messaging/providers/discord/send.test.ts +46 -1
  104. package/src/messaging/providers/discord/send.ts +51 -0
  105. package/src/messaging/providers/discord/transport.ts +26 -3
  106. package/src/messaging/providers/index.ts +22 -47
  107. package/src/messaging/providers/slack/send.test.ts +83 -26
  108. package/src/messaging/providers/slack/send.ts +120 -51
  109. package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
  110. package/src/messaging/providers/slack/stream-tasks.ts +39 -0
  111. package/src/messaging/providers/slack/transport.ts +24 -22
  112. package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
  113. package/src/messaging/providers/telegram-bot/send.ts +43 -0
  114. package/src/messaging/providers/telegram-bot/transport.ts +25 -8
  115. package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
  116. package/src/notifications/adapters/telegram.ts +48 -1
  117. package/src/notifications/assistant-reply-producer.ts +7 -7
  118. package/src/notifications/conversation-seed-composer.ts +7 -2
  119. package/src/oauth/byo-connection.test.ts +63 -0
  120. package/src/oauth/byo-connection.ts +16 -15
  121. package/src/oauth/connection.test.ts +111 -0
  122. package/src/oauth/connection.ts +142 -1
  123. package/src/oauth/platform-connection.test.ts +34 -0
  124. package/src/oauth/platform-connection.ts +28 -5
  125. package/src/oauth/seed-providers.ts +15 -1
  126. package/src/permissions/types.ts +3 -1
  127. package/src/persistence/conversation-crud.ts +46 -0
  128. package/src/persistence/conversation-types.ts +11 -9
  129. package/src/persistence/db-async-query.ts +2 -1
  130. package/src/persistence/db-maintenance.ts +15 -0
  131. package/src/persistence/embeddings/qdrant-manager.ts +1 -0
  132. package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
  133. package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
  134. package/src/persistence/migrations/369-create-app-pins.ts +37 -0
  135. package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
  136. package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
  137. package/src/persistence/schema/index.ts +1 -0
  138. package/src/persistence/schema/infrastructure.ts +17 -0
  139. package/src/persistence/schema/watch.ts +29 -0
  140. package/src/persistence/steps.ts +6 -0
  141. package/src/plugins/mtime-cache.ts +11 -0
  142. package/src/providers/__tests__/retry-network-error.test.ts +84 -0
  143. package/src/providers/connection-resolution.ts +23 -1
  144. package/src/providers/content-blocks.ts +9 -0
  145. package/src/providers/fetch-provider-catalog.ts +19 -0
  146. package/src/providers/gemini/client.ts +13 -5
  147. package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
  148. package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
  149. package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
  150. package/src/providers/inference/adapter-factory.ts +0 -9
  151. package/src/providers/inference/credential-rotation.ts +61 -0
  152. package/src/providers/inference/endpoint-probe.ts +115 -0
  153. package/src/providers/inference/profile-probe.ts +256 -0
  154. package/src/providers/model-catalog.ts +170 -125
  155. package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
  156. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
  157. package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
  158. package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
  159. package/src/providers/openai/api-error-normalization.ts +16 -2
  160. package/src/providers/openai/chat-completions-provider.ts +75 -29
  161. package/src/providers/openai/responses-provider.ts +5 -2
  162. package/src/providers/openrouter/client.ts +0 -1
  163. package/src/providers/provider-send-message.ts +11 -0
  164. package/src/providers/retry.ts +6 -0
  165. package/src/providers/search-provider-catalog.ts +20 -0
  166. package/src/providers/vercel-ai-gateway/client.ts +0 -1
  167. package/src/runtime/AGENTS.md +1 -0
  168. package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
  169. package/src/runtime/__tests__/host-observe.test.ts +302 -0
  170. package/src/runtime/channel-readiness-service.ts +214 -14
  171. package/src/runtime/channel-readiness-types.ts +49 -2
  172. package/src/runtime/channel-reply-delivery.ts +2 -2
  173. package/src/runtime/desktop-presence.ts +24 -21
  174. package/src/runtime/host-observe.ts +246 -0
  175. package/src/runtime/http-server.ts +181 -1
  176. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
  177. package/src/runtime/migrations/staged-import-path.ts +116 -0
  178. package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
  179. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
  180. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
  181. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
  182. package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
  183. package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
  184. package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
  185. package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
  186. package/src/runtime/routes/app-management-routes.ts +140 -29
  187. package/src/runtime/routes/channel-availability-routes.ts +1 -0
  188. package/src/runtime/routes/channel-readiness-routes.ts +14 -2
  189. package/src/runtime/routes/conversation-query-routes.ts +10 -0
  190. package/src/runtime/routes/guardian-approval-interception.ts +24 -33
  191. package/src/runtime/routes/host-cu-routes.ts +18 -0
  192. package/src/runtime/routes/identity-routes.ts +2 -0
  193. package/src/runtime/routes/inbound-message-handler.ts +10 -7
  194. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
  195. package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
  196. package/src/runtime/routes/index.ts +2 -0
  197. package/src/runtime/routes/inference-profiles-routes.ts +232 -31
  198. package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
  199. package/src/runtime/routes/ingress-status-routes.ts +180 -0
  200. package/src/runtime/routes/live-voice-routes.test.ts +40 -1
  201. package/src/runtime/routes/live-voice-routes.ts +34 -0
  202. package/src/runtime/routes/migration-routes.ts +218 -10
  203. package/src/runtime/routes/oauth-commands-routes.ts +23 -16
  204. package/src/runtime/routes/plugins-routes.ts +12 -28
  205. package/src/runtime/routes/question-routes.ts +6 -0
  206. package/src/runtime/routes/secret-routes.ts +7 -27
  207. package/src/runtime/routes/settings-routes.ts +9 -6
  208. package/src/runtime/routes/watch-routes.ts +807 -0
  209. package/src/runtime/slack-reply-session.test.ts +230 -121
  210. package/src/runtime/slack-reply-session.ts +113 -81
  211. package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
  212. package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
  213. package/src/security/__tests__/untrusted-content.test.ts +42 -0
  214. package/src/security/untrusted-content.ts +28 -9
  215. package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
  216. package/src/telemetry/live-voice-funnel.ts +75 -8
  217. package/src/tools/credentials/store.ts +18 -6
  218. package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
  219. package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
  220. package/src/tools/network/__tests__/web-search.test.ts +97 -2
  221. package/src/tools/network/firecrawl-compat.ts +90 -0
  222. package/src/tools/network/web-fetch.ts +142 -62
  223. package/src/tools/network/web-search.ts +141 -55
  224. package/src/tools/types.ts +2 -1
  225. package/src/util/oauth-request-body.test.ts +74 -0
  226. package/src/util/oauth-request-body.ts +60 -0
  227. package/src/util/worker-process.ts +1 -0
  228. package/src/watch/__tests__/watch-retro.test.ts +665 -0
  229. package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
  230. package/src/watch/__tests__/watch-timeline.test.ts +670 -0
  231. package/src/watch/watch-retro.ts +480 -0
  232. package/src/watch/watch-session-manager.ts +575 -0
  233. package/src/watch/watch-timeline.ts +848 -0
@@ -13,6 +13,15 @@ const sendCalls: Array<{
13
13
  /** When true, sendTelegramReply throws if an approval argument is present. */
14
14
  let rejectRichDelivery = false;
15
15
 
16
+ const editCalls: Array<{
17
+ chatId: string;
18
+ messageId: string;
19
+ text: string;
20
+ }> = [];
21
+
22
+ /** When set, editTelegramMessage rejects with this message. */
23
+ let editFailure: string | undefined;
24
+
16
25
  mock.module("../messaging/providers/telegram-bot/send.js", () => ({
17
26
  sendTelegramReply: async (
18
27
  chatId: string,
@@ -37,6 +46,16 @@ mock.module("../messaging/providers/telegram-bot/send.js", () => ({
37
46
  totalCount: 0,
38
47
  }),
39
48
  sendTelegramTypingIndicator: async () => true,
49
+ editTelegramMessage: async (
50
+ chatId: string,
51
+ messageId: string,
52
+ text: string,
53
+ ) => {
54
+ if (editFailure) {
55
+ throw new Error(editFailure);
56
+ }
57
+ editCalls.push({ chatId, messageId, text });
58
+ },
40
59
  }));
41
60
 
42
61
  import { TelegramAdapter } from "../notifications/adapters/telegram.js";
@@ -72,7 +91,9 @@ function makeDestination(
72
91
  describe("TelegramAdapter", () => {
73
92
  beforeEach(() => {
74
93
  sendCalls.length = 0;
94
+ editCalls.length = 0;
75
95
  rejectRichDelivery = false;
96
+ editFailure = undefined;
76
97
  });
77
98
 
78
99
  test("prefers deliveryText and does not append deterministic label", async () => {
@@ -301,4 +322,85 @@ describe("TelegramAdapter", () => {
301
322
  );
302
323
  expect(call.text).toContain("XYZW");
303
324
  });
325
+
326
+ describe("update", () => {
327
+ test("edits the delivered message in place and keeps its id", async () => {
328
+ const adapter = new TelegramAdapter();
329
+
330
+ const result = await adapter.update(
331
+ {
332
+ deliveryId: "del-1",
333
+ destination: "chat-123",
334
+ messageId: "5150",
335
+ conversationId: null,
336
+ },
337
+ { body: "Approved by Alice" },
338
+ );
339
+
340
+ expect(result.success).toBe(true);
341
+ expect(editCalls).toEqual([
342
+ { chatId: "chat-123", messageId: "5150", text: "Approved by Alice" },
343
+ ]);
344
+ // An edit addresses one message and leaves it in place, so the delivery
345
+ // row's id must still identify the card afterwards.
346
+ expect(result.messageId).toBe("5150");
347
+ // Revising a card must never post a second one beside it.
348
+ expect(sendCalls).toHaveLength(0);
349
+ });
350
+
351
+ test("falls back to the title when no body is supplied", async () => {
352
+ const adapter = new TelegramAdapter();
353
+
354
+ await adapter.update(
355
+ {
356
+ deliveryId: "del-1",
357
+ destination: "chat-123",
358
+ messageId: "5150",
359
+ conversationId: null,
360
+ },
361
+ { title: "Expired" },
362
+ );
363
+
364
+ expect(editCalls[0]?.text).toBe("Expired");
365
+ });
366
+
367
+ test("refuses a delivery that captured no message id", async () => {
368
+ const adapter = new TelegramAdapter();
369
+
370
+ const result = await adapter.update(
371
+ {
372
+ deliveryId: "del-1",
373
+ destination: "chat-123",
374
+ messageId: null,
375
+ conversationId: null,
376
+ },
377
+ { body: "Approved" },
378
+ );
379
+
380
+ expect(result.success).toBe(false);
381
+ expect(result.error).toContain("missing_message_id");
382
+ expect(editCalls).toHaveLength(0);
383
+ });
384
+
385
+ test("reports a failed edit rather than posting a replacement", async () => {
386
+ const adapter = new TelegramAdapter();
387
+ editFailure = "Telegram API error: message to edit not found";
388
+
389
+ const result = await adapter.update(
390
+ {
391
+ deliveryId: "del-1",
392
+ destination: "chat-123",
393
+ messageId: "5150",
394
+ conversationId: null,
395
+ },
396
+ { body: "Approved" },
397
+ );
398
+
399
+ expect(result.success).toBe(false);
400
+ expect(result.error).toContain("message to edit not found");
401
+ // The original would otherwise sit beside the replacement, which reads
402
+ // as the assistant answering twice.
403
+ expect(sendCalls).toHaveLength(0);
404
+ });
405
+ });
304
406
  });
@@ -656,6 +656,95 @@ describe("POST oauth/request", () => {
656
656
  expect(result.body).toEqual({ hello: "world" });
657
657
  });
658
658
 
659
+ test("passes a pre-parsed string body through to the connection unchanged", async () => {
660
+ const multipart =
661
+ "--boundary\r\nContent-Type: application/json\r\n\r\n{}\r\n--boundary--\r\n";
662
+
663
+ await getRoute("POST", "oauth/request").handler(
664
+ makeArgs({
665
+ body: {
666
+ provider: "google",
667
+ url: "https://api.google.com/upload/drive/v3/files",
668
+ method: "POST",
669
+ headers: { "Content-Type": "multipart/related; boundary=boundary" },
670
+ parsed_data: multipart,
671
+ },
672
+ }),
673
+ );
674
+
675
+ expect(mockResolveRequests).toHaveLength(1);
676
+ const req = mockResolveRequests[0] as {
677
+ body: unknown;
678
+ headers: Record<string, string>;
679
+ };
680
+ expect(req.body).toBe(multipart);
681
+ expect(req.headers["Content-Type"]).toBe(
682
+ "multipart/related; boundary=boundary",
683
+ );
684
+ });
685
+
686
+ test("keeps a raw data string raw under a non-JSON Content-Type", async () => {
687
+ await getRoute("POST", "oauth/request").handler(
688
+ makeArgs({
689
+ body: {
690
+ provider: "google",
691
+ url: "https://api.google.com/upload/drive/v3/files",
692
+ method: "POST",
693
+ headers: { "content-type": "text/csv" },
694
+ data: '{"looks":"like json"}',
695
+ },
696
+ }),
697
+ );
698
+
699
+ const req = mockResolveRequests[0] as { body: unknown };
700
+ expect(req.body).toBe('{"looks":"like json"}');
701
+ });
702
+
703
+ test("parses a raw data string as JSON under a JSON Content-Type", async () => {
704
+ await getRoute("POST", "oauth/request").handler(
705
+ makeArgs({
706
+ body: {
707
+ provider: "google",
708
+ url: "https://api.google.com/v1/sheets",
709
+ method: "POST",
710
+ headers: { "Content-Type": "application/json" },
711
+ data: '{"title":"Sheet"}',
712
+ },
713
+ }),
714
+ );
715
+
716
+ const req = mockResolveRequests[0] as { body: unknown };
717
+ expect(req.body).toEqual({ title: "Sheet" });
718
+ });
719
+
720
+ test("base64-encodes binary response bodies for the JSON envelope", async () => {
721
+ const binary = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00]);
722
+ mockResolveResponse = {
723
+ status: 200,
724
+ headers: { "content-type": "application/octet-stream" },
725
+ body: binary,
726
+ };
727
+ const result = (await getRoute("POST", "oauth/request").handler(
728
+ makeArgs({
729
+ body: {
730
+ provider: "google",
731
+ url: "https://api.google.com/drive/v3/files/file-123?alt=media",
732
+ },
733
+ }),
734
+ )) as {
735
+ ok: boolean;
736
+ status: number;
737
+ body: unknown;
738
+ bodyEncoding?: string;
739
+ };
740
+ expect(result.ok).toBe(true);
741
+ expect(result.body).toBe(binary.toString("base64"));
742
+ expect(result.bodyEncoding).toBe("base64");
743
+ expect(Buffer.from(String(result.body), "base64").equals(binary)).toBe(
744
+ true,
745
+ );
746
+ });
747
+
659
748
  test("rejects absolute URL host outside provider base host when no injection templates exist", async () => {
660
749
  await expect(
661
750
  getRoute("POST", "oauth/request").handler(
@@ -14,16 +14,15 @@ await initializeDb();
14
14
  seedOAuthProviders();
15
15
 
16
16
  describe("oauth provider profiles (DB-seeded)", () => {
17
- test("google provider row includes Drive in default scopes", () => {
17
+ test("google provider row includes Drive, Sheets, and Slides in default scopes", () => {
18
18
  const provider = getProvider("google");
19
19
 
20
20
  expect(provider).toBeDefined();
21
- expect(JSON.parse(provider!.defaultScopes)).toContain(
22
- "https://www.googleapis.com/auth/drive",
23
- );
21
+ const scopes = JSON.parse(provider!.defaultScopes) as string[];
22
+ expect(scopes).toContain("https://www.googleapis.com/auth/drive");
24
23
  });
25
24
 
26
- test("google provider row contains bearer injection templates for 6 Google API hosts", () => {
25
+ test("google provider row contains bearer injection templates for 8 Google API hosts", () => {
27
26
  const provider = getProvider("google");
28
27
 
29
28
  expect(provider).toBeDefined();
@@ -36,7 +35,7 @@ describe("oauth provider profiles (DB-seeded)", () => {
36
35
  valuePrefix: string;
37
36
  }>;
38
37
 
39
- expect(templates).toHaveLength(6);
38
+ expect(templates).toHaveLength(8);
40
39
 
41
40
  const byHost = new Map(templates.map((t) => [t.hostPattern, t]));
42
41
 
@@ -45,6 +44,8 @@ describe("oauth provider profiles (DB-seeded)", () => {
45
44
  "www.googleapis.com",
46
45
  "people.googleapis.com",
47
46
  "docs.googleapis.com",
47
+ "sheets.googleapis.com",
48
+ "slides.googleapis.com",
48
49
  "tasks.googleapis.com",
49
50
  "calendar.googleapis.com",
50
51
  ]) {
@@ -82,9 +82,27 @@ class FakeAPIError extends Error {
82
82
  }
83
83
  }
84
84
 
85
+ // Simulate the SDK's transport-failure and caller-abort subclasses; the
86
+ // error normalizer distinguishes them by class, so the mock must define both
87
+ // for suites that share this process.
88
+ class FakeAPIConnectionError extends FakeAPIError {
89
+ constructor() {
90
+ super(undefined as unknown as number, "Connection error.");
91
+ this.name = "APIConnectionError";
92
+ }
93
+ }
94
+ class FakeAPIUserAbortError extends FakeAPIError {
95
+ constructor() {
96
+ super(undefined as unknown as number, "Request was aborted.");
97
+ this.name = "APIUserAbortError";
98
+ }
99
+ }
100
+
85
101
  mock.module("openai", () => ({
86
102
  default: class MockOpenAI {
87
103
  static APIError = FakeAPIError;
104
+ static APIConnectionError = FakeAPIConnectionError;
105
+ static APIUserAbortError = FakeAPIUserAbortError;
88
106
  constructor(opts: Record<string, unknown>) {
89
107
  lastConstructorOptions = opts;
90
108
  }
@@ -50,9 +50,27 @@ class FakeAPIError extends Error {
50
50
  }
51
51
  }
52
52
 
53
+ // Simulate the SDK's transport-failure and caller-abort subclasses; the
54
+ // error normalizer distinguishes them by class, so the mock must define both
55
+ // for suites that share this process.
56
+ class FakeAPIConnectionError extends FakeAPIError {
57
+ constructor() {
58
+ super(undefined as unknown as number, "Connection error.");
59
+ this.name = "APIConnectionError";
60
+ }
61
+ }
62
+ class FakeAPIUserAbortError extends FakeAPIError {
63
+ constructor() {
64
+ super(undefined as unknown as number, "Request was aborted.");
65
+ this.name = "APIUserAbortError";
66
+ }
67
+ }
68
+
53
69
  mock.module("openai", () => ({
54
70
  default: class MockOpenAI {
55
71
  static APIError = FakeAPIError;
72
+ static APIConnectionError = FakeAPIConnectionError;
73
+ static APIUserAbortError = FakeAPIUserAbortError;
56
74
  constructor(opts: Record<string, unknown>) {
57
75
  lastConstructorOptions = opts;
58
76
  }
@@ -1,11 +1,14 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import { setIngressPublicBaseUrl } from "../config/env.js";
3
4
  import { credentialKey } from "../security/credential-key.js";
4
5
 
5
6
  let mockIsPlatform = true;
6
7
  let mockPlatformBaseUrl = "";
7
8
  let mockPlatformAssistantId = "";
8
9
  let mockSecureKeys: Record<string, string> = {};
10
+ let mockConfig: { ingress?: { publicBaseUrl?: string; enabled?: boolean } } =
11
+ {};
9
12
 
10
13
  // Bun shares mocked modules across test files in a combined run, so each mock
11
14
  // spreads the real module and overrides only what this file drives. Replacing
@@ -30,6 +33,12 @@ mock.module("../security/secure-keys.js", () => ({
30
33
  getSecureKeyAsync: async (key: string) => mockSecureKeys[key] ?? undefined,
31
34
  }));
32
35
 
36
+ const actualLoader = await import("../config/loader.js");
37
+ mock.module("../config/loader.js", () => ({
38
+ ...actualLoader,
39
+ getConfig: () => mockConfig,
40
+ }));
41
+
33
42
  const originalFetch = globalThis.fetch;
34
43
  const originalEnvCredential = process.env.ASSISTANT_API_KEY;
35
44
 
@@ -48,6 +57,8 @@ describe("platform callback registration", () => {
48
57
  mockPlatformBaseUrl = "";
49
58
  mockPlatformAssistantId = "";
50
59
  mockSecureKeys = {};
60
+ mockConfig = {};
61
+ setIngressPublicBaseUrl(undefined);
51
62
  delete process.env.ASSISTANT_API_KEY;
52
63
  globalThis.fetch = originalFetch;
53
64
  });
@@ -155,6 +166,177 @@ describe("platform callback registration", () => {
155
166
  registerCallbackRoute("webhooks/telegram", "telegram"),
156
167
  ).resolves.toBe("https://platform.example.com/v1/gateway/callbacks/x/");
157
168
  });
169
+
170
+ test("self-hosted registerCallbackRoute sends configured callback_base_url", async () => {
171
+ mockIsPlatform = false;
172
+ mockConfig = {
173
+ ingress: { publicBaseUrl: "https://my-assistant.example.com" },
174
+ };
175
+ mockSecureKeys[credentialKey("vellum", "platform_base_url")] =
176
+ "https://platform.example.com";
177
+ mockSecureKeys[credentialKey("vellum", "platform_assistant_id")] =
178
+ "22222222-3333-4444-8555-666666666666";
179
+ mockSecureKeys[credentialKey("vellum", "assistant_api_key")] =
180
+ "ast-self-hosted-key";
181
+
182
+ globalThis.fetch = mock(
183
+ async (_input: RequestInfo | URL, init?: RequestInit) => {
184
+ expect(JSON.parse(String(init?.body))).toEqual({
185
+ assistant_id: "22222222-3333-4444-8555-666666666666",
186
+ callback_path: "webhooks/telegram",
187
+ type: "telegram",
188
+ callback_base_url: "https://my-assistant.example.com",
189
+ });
190
+
191
+ return new Response(
192
+ JSON.stringify({
193
+ callback_url:
194
+ "https://my-assistant.example.com/v1/gateway/callbacks/x/",
195
+ callback_path:
196
+ "22222222-3333-4444-8555-666666666666/webhooks/telegram",
197
+ type: "telegram",
198
+ assistant_id: "22222222-3333-4444-8555-666666666666",
199
+ }),
200
+ {
201
+ status: 201,
202
+ headers: { "content-type": "application/json" },
203
+ },
204
+ );
205
+ },
206
+ ) as unknown as typeof fetch;
207
+
208
+ await expect(
209
+ registerCallbackRoute("webhooks/telegram", "telegram"),
210
+ ).resolves.toBe(
211
+ "https://my-assistant.example.com/v1/gateway/callbacks/x/",
212
+ );
213
+ });
214
+
215
+ test("self-hosted registerCallbackRoute sends detected module-level callback_base_url", async () => {
216
+ mockIsPlatform = false;
217
+ mockConfig = {};
218
+ setIngressPublicBaseUrl("https://detected.example.com/");
219
+ mockSecureKeys[credentialKey("vellum", "platform_base_url")] =
220
+ "https://platform.example.com";
221
+ mockSecureKeys[credentialKey("vellum", "platform_assistant_id")] =
222
+ "22222222-3333-4444-8555-666666666666";
223
+ mockSecureKeys[credentialKey("vellum", "assistant_api_key")] =
224
+ "ast-self-hosted-key";
225
+
226
+ globalThis.fetch = mock(
227
+ async (_input: RequestInfo | URL, init?: RequestInit) => {
228
+ expect(JSON.parse(String(init?.body))).toEqual({
229
+ assistant_id: "22222222-3333-4444-8555-666666666666",
230
+ callback_path: "webhooks/telegram",
231
+ type: "telegram",
232
+ callback_base_url: "https://detected.example.com",
233
+ });
234
+
235
+ return new Response(
236
+ JSON.stringify({
237
+ callback_url:
238
+ "https://detected.example.com/v1/gateway/callbacks/x/",
239
+ callback_path:
240
+ "22222222-3333-4444-8555-666666666666/webhooks/telegram",
241
+ type: "telegram",
242
+ assistant_id: "22222222-3333-4444-8555-666666666666",
243
+ }),
244
+ {
245
+ status: 201,
246
+ headers: { "content-type": "application/json" },
247
+ },
248
+ );
249
+ },
250
+ ) as unknown as typeof fetch;
251
+
252
+ await expect(
253
+ registerCallbackRoute("webhooks/telegram", "telegram"),
254
+ ).resolves.toBe("https://detected.example.com/v1/gateway/callbacks/x/");
255
+ });
256
+
257
+ test("self-hosted registerCallbackRoute omits callback_base_url when no ingress is available", async () => {
258
+ mockIsPlatform = false;
259
+ mockConfig = {};
260
+ mockSecureKeys[credentialKey("vellum", "platform_base_url")] =
261
+ "https://platform.example.com";
262
+ mockSecureKeys[credentialKey("vellum", "platform_assistant_id")] =
263
+ "22222222-3333-4444-8555-666666666666";
264
+ mockSecureKeys[credentialKey("vellum", "assistant_api_key")] =
265
+ "ast-self-hosted-key";
266
+
267
+ globalThis.fetch = mock(
268
+ async (_input: RequestInfo | URL, init?: RequestInit) => {
269
+ const body = JSON.parse(String(init?.body)) as Record<string, string>;
270
+ expect(body).toEqual({
271
+ assistant_id: "22222222-3333-4444-8555-666666666666",
272
+ callback_path: "webhooks/telegram",
273
+ type: "telegram",
274
+ });
275
+ expect(body).not.toHaveProperty("callback_base_url");
276
+
277
+ return new Response(
278
+ JSON.stringify({
279
+ callback_url:
280
+ "https://platform.example.com/v1/gateway/callbacks/x/",
281
+ callback_path:
282
+ "22222222-3333-4444-8555-666666666666/webhooks/telegram",
283
+ type: "telegram",
284
+ assistant_id: "22222222-3333-4444-8555-666666666666",
285
+ }),
286
+ {
287
+ status: 201,
288
+ headers: { "content-type": "application/json" },
289
+ },
290
+ );
291
+ },
292
+ ) as unknown as typeof fetch;
293
+
294
+ await expect(
295
+ registerCallbackRoute("webhooks/telegram", "telegram"),
296
+ ).resolves.toBe("https://platform.example.com/v1/gateway/callbacks/x/");
297
+ });
298
+
299
+ test("platform-managed registerCallbackRoute omits callback_base_url even when ingress exists", async () => {
300
+ mockIsPlatform = true;
301
+ mockConfig = { ingress: { publicBaseUrl: "https://velay.example.com" } };
302
+ mockSecureKeys[credentialKey("vellum", "platform_base_url")] =
303
+ "https://platform.example.com";
304
+ mockSecureKeys[credentialKey("vellum", "platform_assistant_id")] =
305
+ "11111111-2222-4333-8444-555555555555";
306
+ mockSecureKeys[credentialKey("vellum", "assistant_api_key")] =
307
+ "ast-managed-key";
308
+
309
+ globalThis.fetch = mock(
310
+ async (_input: RequestInfo | URL, init?: RequestInit) => {
311
+ const body = JSON.parse(String(init?.body)) as Record<string, string>;
312
+ expect(body).not.toHaveProperty("callback_base_url");
313
+ expect(body).toEqual({
314
+ assistant_id: "11111111-2222-4333-8444-555555555555",
315
+ callback_path: "webhooks/telegram",
316
+ type: "telegram",
317
+ });
318
+
319
+ return new Response(
320
+ JSON.stringify({
321
+ callback_url:
322
+ "https://platform.example.com/v1/gateway/callbacks/x/",
323
+ callback_path:
324
+ "11111111-2222-4333-8444-555555555555/webhooks/telegram",
325
+ type: "telegram",
326
+ assistant_id: "11111111-2222-4333-8444-555555555555",
327
+ }),
328
+ {
329
+ status: 201,
330
+ headers: { "content-type": "application/json" },
331
+ },
332
+ );
333
+ },
334
+ ) as unknown as typeof fetch;
335
+
336
+ await expect(
337
+ registerCallbackRoute("webhooks/telegram", "telegram"),
338
+ ).resolves.toBe("https://platform.example.com/v1/gateway/callbacks/x/");
339
+ });
158
340
  });
159
341
 
160
342
  /**
@@ -194,6 +376,8 @@ describe("resolveCallbackUrl resolution order", () => {
194
376
  mockPlatformBaseUrl = "";
195
377
  mockPlatformAssistantId = "";
196
378
  mockSecureKeys = {};
379
+ mockConfig = {};
380
+ setIngressPublicBaseUrl(undefined);
197
381
  delete process.env.ASSISTANT_API_KEY;
198
382
  registerCalls = 0;
199
383
  globalThis.fetch = mock(async () => {
@@ -13,9 +13,14 @@ import {
13
13
  } from "bun:test";
14
14
 
15
15
  import { credentialKey } from "@vellumai/credential-storage";
16
+ import { eq } from "drizzle-orm";
16
17
 
18
+ import * as conversationStore from "../daemon/conversation-store.js";
17
19
  import * as scrub from "../daemon/credential-transcript-scrub.js";
18
20
  import * as manualToken from "../oauth/manual-token-connection.js";
21
+ import { getDb } from "../persistence/db-connection.js";
22
+ import { initializeDb } from "../persistence/db-init.js";
23
+ import { providerConnections } from "../persistence/schema/inference.js";
19
24
  import { resolveCredential } from "../plugin-api/resolve-credential.js";
20
25
  import {
21
26
  CredentialStoreError,
@@ -54,6 +59,9 @@ let setSpy: ReturnType<typeof spyOn>;
54
59
  let getSpy: ReturnType<typeof spyOn>;
55
60
  let scrubSpy: ReturnType<typeof spyOn>;
56
61
  let syncSpy: ReturnType<typeof spyOn>;
62
+ let evictSpy: ReturnType<typeof spyOn>;
63
+
64
+ await initializeDb();
57
65
 
58
66
  beforeEach(() => {
59
67
  if (existsSync(TEST_DIR)) {
@@ -89,6 +97,14 @@ beforeEach(() => {
89
97
  syncSpy = spyOn(manualToken, "syncManualTokenConnection").mockImplementation(
90
98
  async () => {},
91
99
  );
100
+ evictSpy = spyOn(
101
+ conversationStore,
102
+ "evictConversationsForReload",
103
+ ).mockImplementation(() => {});
104
+ getDb()
105
+ .delete(providerConnections)
106
+ .where(eq(providerConnections.name, "openrouter-connection"))
107
+ .run();
92
108
  });
93
109
 
94
110
  afterEach(() => {
@@ -96,6 +112,7 @@ afterEach(() => {
96
112
  getSpy.mockRestore();
97
113
  scrubSpy.mockRestore();
98
114
  syncSpy.mockRestore();
115
+ evictSpy.mockRestore();
99
116
  });
100
117
 
101
118
  afterAll(() => {
@@ -198,14 +215,65 @@ describe("storeCredential", () => {
198
215
  expect(syncSpy).toHaveBeenCalledWith("acme");
199
216
  });
200
217
 
218
+ /**
219
+ * Verifies that storeCredentialValue refreshes providers for dependent connections.
220
+ */
221
+ test("refreshes providers when a stored credential backs a connection", async () => {
222
+ // GIVEN a provider connection references the credential being stored.
223
+ const now = Date.now();
224
+ getDb()
225
+ .insert(providerConnections)
226
+ .values({
227
+ name: "openrouter-connection",
228
+ provider: "openai",
229
+ auth: JSON.stringify({
230
+ type: "api_key",
231
+ credential: credentialKey("openrouter", "api_key"),
232
+ }),
233
+ createdAt: now,
234
+ updatedAt: now,
235
+ })
236
+ .run();
237
+
238
+ // AND the credential is non-managed.
239
+ // WHEN the credential is stored.
240
+ await storeCredentialValue({
241
+ service: "openrouter",
242
+ field: "api_key",
243
+ value: "openrouter-key",
244
+ skipTranscriptScrub: true,
245
+ });
246
+
247
+ // THEN the provider refresh runs once.
248
+ expect(evictSpy).toHaveBeenCalledTimes(1);
249
+ });
250
+
251
+ /**
252
+ * Verifies that storeCredentialValue skips refresh for an unused credential.
253
+ */
254
+ test("does not refresh providers when a stored credential backs no connection", async () => {
255
+ // GIVEN no provider connection references the credential being stored.
256
+ // AND the credential is non-managed.
257
+ // WHEN the credential is stored.
258
+ await storeCredentialValue({
259
+ service: "acme",
260
+ field: "api_key",
261
+ value: "acme-key",
262
+ skipTranscriptScrub: true,
263
+ });
264
+
265
+ // THEN the provider refresh does not run.
266
+ expect(evictSpy).toHaveBeenCalledTimes(0);
267
+ });
268
+
201
269
  describe("plugin scoping", () => {
202
270
  test("fails closed with no plugin in context", async () => {
203
271
  // A plugin's module body is evaluated outside any context. An unscoped
204
272
  // branch there would let top-level code overwrite the user's own
205
273
  // credentials, so the write is refused instead.
206
- await expect(storeCredential("acme/api_key", "sk-secret")).rejects.toThrow(
207
- /requires an active plugin execution context/,
208
- );
274
+ await expect(
275
+ storeCredential("acme/api_key", "sk-secret"),
276
+ ).rejects.toThrow(/requires an active plugin execution context/);
209
277
  expect(setSpy).not.toHaveBeenCalled();
210
278
  expect(getCredentialMetadata("acme", "api_key")).toBeUndefined();
211
279
  });
@@ -834,8 +834,8 @@ describe("Anthropic models on OpenRouter", () => {
834
834
  });
835
835
 
836
836
  expect(result.pricingStatus).toBe("priced");
837
- // 0.05M x $0.1 direct + 0.05M x $0.125 write + 0.05M x $0.01 read
838
- expect(result.estimatedCostUsd).toBeCloseTo(0.005 + 0.00625 + 0.0005, 10);
837
+ // 0.05M x $0.2 direct + 0.05M x $0.25 write + 0.05M x $0.02 read
838
+ expect(result.estimatedCostUsd).toBeCloseTo(0.01 + 0.0125 + 0.001, 10);
839
839
  });
840
840
 
841
841
  test("returns unpriced for unknown non-Anthropic OpenRouter model", () => {