@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
@@ -28,6 +28,10 @@ import type {
28
28
  ToolExecutionResult,
29
29
  } from "../types.js";
30
30
  import { extractDomain } from "./domain-normalize.js";
31
+ import {
32
+ FASTCRW_DEFAULT_API_BASE,
33
+ resolveProviderApiUrl,
34
+ } from "./firecrawl-compat.js";
31
35
  import {
32
36
  buildHostHeader,
33
37
  isIPv4,
@@ -46,6 +50,7 @@ import {
46
50
  const log = getLogger("web-fetch");
47
51
 
48
52
  const FIRECRAWL_SCRAPE_API_URL = "https://api.firecrawl.dev/v2/scrape";
53
+ const FASTCRW_SCRAPE_PATH = "/v1/scrape";
49
54
 
50
55
  const DEFAULT_TIMEOUT_SECONDS = 20;
51
56
  const MAX_TIMEOUT_SECONDS = 60;
@@ -1114,25 +1119,29 @@ interface FirecrawlScrapeResponse {
1114
1119
 
1115
1120
  function getWebFetchProvider(): WebFetchProviderId {
1116
1121
  const configured = getConfig().services["web-fetch"]?.provider ?? "default";
1117
- return configured === "firecrawl" ? "firecrawl" : "default";
1122
+ if (configured === "firecrawl" || configured === "fastcrw") {
1123
+ return configured;
1124
+ }
1125
+ return "default";
1118
1126
  }
1119
1127
 
1120
1128
  /**
1121
- * Decide whether a request may be routed to the hosted Firecrawl provider.
1129
+ * Decide whether a request may be routed to a hosted scrape provider
1130
+ * (Firecrawl / fastCRW).
1122
1131
  *
1123
- * Posting a URL to Firecrawl sends its path + query (which can hold secrets) to
1124
- * a third party, so we apply the SAME safety gate as the built-in fetcher
1125
- * BEFORE dispatching — not just the lexical host check:
1126
- * - only http(s) URLs (Firecrawl can't do other schemes anyway),
1132
+ * Posting a URL to a hosted scraper sends its path + query (which can hold
1133
+ * secrets) to a third party, so we apply the SAME safety gate as the built-in
1134
+ * fetcher BEFORE dispatching — not just the lexical host check:
1135
+ * - only http(s) URLs (hosted scrapers can't do other schemes anyway),
1127
1136
  * - never `allow_private_network` requests (those are intentionally local;
1128
- * Firecrawl can't reach them and the built-in path owns that mode),
1137
+ * hosted scrapers can't reach them and the built-in path owns that mode),
1129
1138
  * - and a DNS resolution check so a public hostname that resolves to a
1130
1139
  * private/blocked address (e.g. `internal.example` → 10.x.x.x) is NOT
1131
- * leaked to Firecrawl.
1140
+ * leaked to the hosted scraper.
1132
1141
  * Anything that fails falls back to the built-in fetcher, which enforces its
1133
1142
  * own SSRF rules and returns the appropriate error.
1134
1143
  */
1135
- async function canRouteToFirecrawl(
1144
+ async function canRouteToHostedScraper(
1136
1145
  input: Record<string, unknown>,
1137
1146
  ): Promise<boolean> {
1138
1147
  if (input.allow_private_network === true) {
@@ -1163,10 +1172,11 @@ async function canRouteToFirecrawl(
1163
1172
  return true;
1164
1173
  }
1165
1174
 
1166
- function firecrawlErrorResult(
1175
+ function hostedScrapeErrorResult(
1167
1176
  requestedUrl: string,
1168
1177
  startedAt: number,
1169
1178
  errorMessage: string,
1179
+ provider: "firecrawl" | "fastcrw",
1170
1180
  status = 0,
1171
1181
  ): ToolExecutionResult {
1172
1182
  const domain = extractDomain(requestedUrl);
@@ -1177,7 +1187,7 @@ function firecrawlErrorResult(
1177
1187
  webFetch: {
1178
1188
  url: requestedUrl,
1179
1189
  finalUrl: requestedUrl,
1180
- provider: "firecrawl",
1190
+ provider,
1181
1191
  status,
1182
1192
  byteCount: 0,
1183
1193
  charCount: 0,
@@ -1193,7 +1203,7 @@ function firecrawlErrorResult(
1193
1203
  }
1194
1204
 
1195
1205
  /**
1196
- * Fetch a page via Firecrawl's hosted `/v2/scrape` endpoint, returning clean
1206
+ * Fetch a page via a Firecrawl-compatible scrape endpoint, returning clean
1197
1207
  * markdown. Mirrors the built-in fetcher's output shape (same
1198
1208
  * {@link formatWebFetchOutput} and {@link WebFetchMetadata}) so the model and
1199
1209
  * client UIs can't tell which backend served the page apart from
@@ -1202,9 +1212,15 @@ function firecrawlErrorResult(
1202
1212
  * Exported for direct unit testing; the registered tool dispatches here via
1203
1213
  * {@link getWebFetchProvider}.
1204
1214
  */
1205
- export async function executeFirecrawlScrape(
1215
+ export async function executeFirecrawlCompatScrape(
1206
1216
  input: Record<string, unknown>,
1207
- options: { apiKey: string; signal?: AbortSignal },
1217
+ options: {
1218
+ apiKey: string;
1219
+ endpoint: string;
1220
+ provider: "firecrawl" | "fastcrw";
1221
+ displayName: string;
1222
+ signal?: AbortSignal;
1223
+ },
1208
1224
  ): Promise<ToolExecutionResult> {
1209
1225
  const startedAt = Date.now();
1210
1226
  const parsedUrl = parseUrl(input.url);
@@ -1215,22 +1231,24 @@ export async function executeFirecrawlScrape(
1215
1231
  : sanitizeUrlStringForOutput(targetUrl);
1216
1232
 
1217
1233
  if (!targetUrl) {
1218
- return firecrawlErrorResult(
1234
+ return hostedScrapeErrorResult(
1219
1235
  safeRequestedUrl,
1220
1236
  startedAt,
1221
1237
  "url is required and must be a valid HTTP(S) URL",
1238
+ options.provider,
1222
1239
  );
1223
1240
  }
1224
1241
 
1225
- // Never forward URL-embedded credentials (user:password@host) to the hosted
1226
- // Firecrawl API. The built-in fetcher turns them into a Basic-auth header to
1227
- // the target; Firecrawl can't, so reject rather than leak them to a third
1228
- // party.
1242
+ // Never forward URL-embedded credentials (user:password@host) to a hosted
1243
+ // scrape API. The built-in fetcher turns them into a Basic-auth header to
1244
+ // the target; hosted scrapers can't, so reject rather than leak them to a
1245
+ // third party.
1229
1246
  if (parsedUrl?.username || parsedUrl?.password) {
1230
- return firecrawlErrorResult(
1247
+ return hostedScrapeErrorResult(
1231
1248
  safeRequestedUrl,
1232
1249
  startedAt,
1233
- "URLs with embedded credentials are not supported by the Firecrawl provider. Remove the user:password@ portion of the URL.",
1250
+ `URLs with embedded credentials are not supported by the ${options.displayName} provider. Remove the user:password@ portion of the URL.`,
1251
+ options.provider,
1234
1252
  );
1235
1253
  }
1236
1254
 
@@ -1255,16 +1273,21 @@ export async function executeFirecrawlScrape(
1255
1273
  timeout: timeoutSeconds * 1000,
1256
1274
  };
1257
1275
 
1276
+ const headers: Record<string, string> = {
1277
+ "Content-Type": "application/json",
1278
+ "X-Client-Source": "vellum-assistant",
1279
+ };
1280
+ const trimmedKey = options.apiKey.trim();
1281
+ if (trimmedKey.length > 0) {
1282
+ headers.Authorization = `Bearer ${trimmedKey}`;
1283
+ }
1284
+
1258
1285
  for (let attempt = 0; attempt <= DEFAULT_MAX_RETRIES; attempt++) {
1259
1286
  let response: Response;
1260
1287
  try {
1261
- response = await fetch(FIRECRAWL_SCRAPE_API_URL, {
1288
+ response = await fetch(options.endpoint, {
1262
1289
  method: "POST",
1263
- headers: {
1264
- "Content-Type": "application/json",
1265
- Authorization: `Bearer ${options.apiKey}`,
1266
- "X-Client-Source": "vellum-assistant",
1267
- },
1290
+ headers,
1268
1291
  body: JSON.stringify(requestBody),
1269
1292
  signal: options.signal,
1270
1293
  });
@@ -1273,17 +1296,19 @@ export async function executeFirecrawlScrape(
1273
1296
  options.signal?.aborted ||
1274
1297
  (err instanceof Error && err.name === "AbortError")
1275
1298
  ) {
1276
- return firecrawlErrorResult(
1299
+ return hostedScrapeErrorResult(
1277
1300
  safeRequestedUrl,
1278
1301
  startedAt,
1279
1302
  "web fetch was cancelled",
1303
+ options.provider,
1280
1304
  );
1281
1305
  }
1282
1306
  const msg = err instanceof Error ? err.message : String(err);
1283
- return firecrawlErrorResult(
1307
+ return hostedScrapeErrorResult(
1284
1308
  safeRequestedUrl,
1285
1309
  startedAt,
1286
- `Firecrawl scrape failed: ${msg}`,
1310
+ `${options.displayName} scrape failed: ${msg}`,
1311
+ options.provider,
1287
1312
  );
1288
1313
  }
1289
1314
 
@@ -1292,24 +1317,26 @@ export async function executeFirecrawlScrape(
1292
1317
  try {
1293
1318
  json = (await response.json()) as FirecrawlScrapeResponse;
1294
1319
  } catch {
1295
- return firecrawlErrorResult(
1320
+ return hostedScrapeErrorResult(
1296
1321
  safeRequestedUrl,
1297
1322
  startedAt,
1298
- "Firecrawl scrape returned an invalid JSON payload.",
1323
+ `${options.displayName} scrape returned an invalid JSON payload.`,
1324
+ options.provider,
1299
1325
  response.status,
1300
1326
  );
1301
1327
  }
1302
1328
  const data = json.data ?? {};
1303
- const fcMeta = data.metadata ?? {};
1329
+ const scrapeMeta = data.metadata ?? {};
1304
1330
  // A 200 can still carry a payload-level failure (success:false, a
1305
1331
  // top-level error, or a per-page error in data.metadata). Surface it
1306
1332
  // instead of treating an empty body as a successful "no content" scrape.
1307
- const payloadError = json.error ?? fcMeta.error;
1333
+ const payloadError = json.error ?? scrapeMeta.error;
1308
1334
  if (json.success === false || payloadError) {
1309
- return firecrawlErrorResult(
1335
+ return hostedScrapeErrorResult(
1310
1336
  safeRequestedUrl,
1311
1337
  startedAt,
1312
- payloadError ?? "Firecrawl scrape failed.",
1338
+ payloadError ?? `${options.displayName} scrape failed.`,
1339
+ options.provider,
1313
1340
  response.status,
1314
1341
  );
1315
1342
  }
@@ -1322,15 +1349,15 @@ export async function executeFirecrawlScrape(
1322
1349
  const sliced = processed.slice(safeStart, safeEnd);
1323
1350
  const bytesRead = Buffer.byteLength(processed, "utf8");
1324
1351
 
1325
- const finalUrlRaw = fcMeta.url ?? fcMeta.sourceURL ?? targetUrl;
1352
+ const finalUrlRaw = scrapeMeta.url ?? scrapeMeta.sourceURL ?? targetUrl;
1326
1353
  const finalUrl = sanitizeUrlStringForOutput(finalUrlRaw);
1327
- const status = fcMeta.statusCode ?? 200;
1328
- const contentType = fcMeta.contentType ?? "text/markdown";
1354
+ const status = scrapeMeta.statusCode ?? 200;
1355
+ const contentType = scrapeMeta.contentType ?? "text/markdown";
1329
1356
 
1330
1357
  const notices: string[] = [];
1331
1358
  const warning = data.warning ?? json.warning;
1332
1359
  if (warning) {
1333
- notices.push(`Firecrawl: ${warning}`);
1360
+ notices.push(`${options.displayName}: ${warning}`);
1334
1361
  }
1335
1362
  if (safeEnd < processed.length) {
1336
1363
  notices.push(`Output truncated by max_chars=${maxChars}.`);
@@ -1352,8 +1379,8 @@ export async function executeFirecrawlScrape(
1352
1379
  startIndex: safeStart,
1353
1380
  endIndex: safeEnd,
1354
1381
  content: sliced,
1355
- title: fcMeta.title,
1356
- description: fcMeta.description,
1382
+ title: scrapeMeta.title,
1383
+ description: scrapeMeta.description,
1357
1384
  notices,
1358
1385
  raw: false,
1359
1386
  markdown: true,
@@ -1363,13 +1390,13 @@ export async function executeFirecrawlScrape(
1363
1390
  const meta: WebFetchMetadata = {
1364
1391
  url: safeRequestedUrl,
1365
1392
  finalUrl,
1366
- provider: "firecrawl",
1393
+ provider: options.provider,
1367
1394
  status,
1368
1395
  contentType,
1369
1396
  byteCount: bytesRead,
1370
1397
  charCount: sliced.length,
1371
1398
  truncated: safeEnd < processed.length,
1372
- title: fcMeta.title,
1399
+ title: scrapeMeta.title,
1373
1400
  domain: finalDomain,
1374
1401
  faviconUrl: faviconUrlForDomain(finalDomain),
1375
1402
  redirectCount: 0,
@@ -1387,10 +1414,11 @@ export async function executeFirecrawlScrape(
1387
1414
  const bodyText = await response.text();
1388
1415
 
1389
1416
  if (response.status === 401 || response.status === 403) {
1390
- return firecrawlErrorResult(
1417
+ return hostedScrapeErrorResult(
1391
1418
  safeRequestedUrl,
1392
1419
  startedAt,
1393
- "Invalid or expired Firecrawl API key",
1420
+ `Invalid or expired ${options.displayName} API key`,
1421
+ options.provider,
1394
1422
  response.status,
1395
1423
  );
1396
1424
  }
@@ -1402,39 +1430,77 @@ export async function executeFirecrawlScrape(
1402
1430
  DEFAULT_BASE_DELAY_MS,
1403
1431
  );
1404
1432
  log.warn(
1405
- { attempt: attempt + 1, delayMs },
1406
- "Firecrawl scrape rate limited, retrying",
1433
+ { attempt: attempt + 1, delayMs, provider: options.provider },
1434
+ `${options.displayName} scrape rate limited, retrying`,
1407
1435
  );
1408
1436
  await sleep(delayMs);
1409
1437
  continue;
1410
1438
  }
1411
1439
 
1412
1440
  log.warn(
1413
- { status: response.status, body: safeStringSlice(bodyText, 0, 200) },
1414
- "Firecrawl scrape API error",
1441
+ {
1442
+ status: response.status,
1443
+ body: safeStringSlice(bodyText, 0, 200),
1444
+ provider: options.provider,
1445
+ },
1446
+ `${options.displayName} scrape API error`,
1415
1447
  );
1416
1448
  const errorMessage =
1417
1449
  response.status === 429
1418
- ? "Firecrawl scrape rate limit exceeded after retries. Try again shortly."
1450
+ ? `${options.displayName} scrape rate limit exceeded after retries. Try again shortly.`
1419
1451
  : response.status === 402
1420
- ? "Firecrawl scrape failed: account balance/credits exhausted."
1421
- : `Firecrawl scrape API returned status ${response.status}`;
1422
- return firecrawlErrorResult(
1452
+ ? `${options.displayName} scrape failed: account balance/credits exhausted.`
1453
+ : `${options.displayName} scrape API returned status ${response.status}`;
1454
+ return hostedScrapeErrorResult(
1423
1455
  safeRequestedUrl,
1424
1456
  startedAt,
1425
1457
  errorMessage,
1458
+ options.provider,
1426
1459
  response.status,
1427
1460
  );
1428
1461
  }
1429
1462
 
1430
- return firecrawlErrorResult(
1463
+ return hostedScrapeErrorResult(
1431
1464
  safeRequestedUrl,
1432
1465
  startedAt,
1433
- "Firecrawl scrape rate limit exceeded after retries. Try again shortly.",
1466
+ `${options.displayName} scrape rate limit exceeded after retries. Try again shortly.`,
1467
+ options.provider,
1434
1468
  429,
1435
1469
  );
1436
1470
  }
1437
1471
 
1472
+ export async function executeFirecrawlScrape(
1473
+ input: Record<string, unknown>,
1474
+ options: { apiKey: string; signal?: AbortSignal },
1475
+ ): Promise<ToolExecutionResult> {
1476
+ return executeFirecrawlCompatScrape(input, {
1477
+ apiKey: options.apiKey,
1478
+ endpoint: FIRECRAWL_SCRAPE_API_URL,
1479
+ provider: "firecrawl",
1480
+ displayName: "Firecrawl",
1481
+ signal: options.signal,
1482
+ });
1483
+ }
1484
+
1485
+ export async function executeFastcrwScrape(
1486
+ input: Record<string, unknown>,
1487
+ options: { apiKey: string; signal?: AbortSignal },
1488
+ ): Promise<ToolExecutionResult> {
1489
+ const apiBase = getConfig().services["web-fetch"]?.apiBase;
1490
+ const endpoint = resolveProviderApiUrl(
1491
+ apiBase,
1492
+ FASTCRW_SCRAPE_PATH,
1493
+ FASTCRW_DEFAULT_API_BASE,
1494
+ );
1495
+ return executeFirecrawlCompatScrape(input, {
1496
+ apiKey: options.apiKey,
1497
+ endpoint,
1498
+ provider: "fastcrw",
1499
+ displayName: "fastCRW",
1500
+ signal: options.signal,
1501
+ });
1502
+ }
1503
+
1438
1504
  export const webFetchTool = {
1439
1505
  name: "web_fetch",
1440
1506
  description:
@@ -1482,19 +1548,33 @@ export const webFetchTool = {
1482
1548
  input: Record<string, unknown>,
1483
1549
  context: ToolContext,
1484
1550
  ): Promise<ToolExecutionResult> {
1551
+ const fetchProvider = getWebFetchProvider();
1485
1552
  if (
1486
- getWebFetchProvider() === "firecrawl" &&
1487
- (await canRouteToFirecrawl(input))
1553
+ (fetchProvider === "firecrawl" || fetchProvider === "fastcrw") &&
1554
+ (await canRouteToHostedScraper(input))
1488
1555
  ) {
1489
- const apiKey = await getProviderKeyAsync("firecrawl");
1490
- if (apiKey) {
1491
- return executeFirecrawlScrape(input, {
1556
+ const apiKey = (await getProviderKeyAsync(fetchProvider)) ?? "";
1557
+ const webFetchApiBase = getConfig().services["web-fetch"]?.apiBase;
1558
+ const allowsKeylessSelfHost =
1559
+ fetchProvider === "fastcrw" &&
1560
+ typeof webFetchApiBase === "string" &&
1561
+ webFetchApiBase.trim().length > 0;
1562
+
1563
+ if (apiKey || allowsKeylessSelfHost) {
1564
+ if (fetchProvider === "firecrawl") {
1565
+ return executeFirecrawlScrape(input, {
1566
+ apiKey,
1567
+ signal: context.signal,
1568
+ });
1569
+ }
1570
+ return executeFastcrwScrape(input, {
1492
1571
  apiKey,
1493
1572
  signal: context.signal,
1494
1573
  });
1495
1574
  }
1496
1575
  log.info(
1497
- "web_fetch provider is firecrawl but no API key is configured; falling back to the built-in fetcher",
1576
+ { provider: fetchProvider },
1577
+ "web_fetch BYOK provider has no API key configured; falling back to the built-in fetcher",
1498
1578
  );
1499
1579
  // Fall through to the built-in fetcher.
1500
1580
  }