@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
@@ -405,7 +405,7 @@ describe("OpenAIChatCompletionsProvider reasoning parsing", () => {
405
405
  expect(assistantMsg.reasoning_content).toBeUndefined();
406
406
  });
407
407
 
408
- test("backfills placeholder content for a reasoning-only assistant turn when enabled", async () => {
408
+ test("backfills placeholder content for a reasoning-only assistant turn", async () => {
409
409
  const { provider, requests } = stubProvider(
410
410
  [
411
411
  {
@@ -413,10 +413,7 @@ describe("OpenAIChatCompletionsProvider reasoning parsing", () => {
413
413
  usage: { prompt_tokens: 2, completion_tokens: 1 },
414
414
  },
415
415
  ],
416
- {
417
- assistantReasoningField: "reasoning",
418
- backfillEmptyAssistantContent: true,
419
- },
416
+ { assistantReasoningField: "reasoning" },
420
417
  );
421
418
 
422
419
  await provider.sendMessage([
@@ -455,53 +452,38 @@ describe("OpenAIChatCompletionsProvider reasoning parsing", () => {
455
452
  expect(EMPTY_ASSISTANT_TURN_PLACEHOLDER).not.toContain("\x00");
456
453
  });
457
454
 
458
- test("leaves reasoning-only assistant content null when backfill is disabled", async () => {
459
- const { provider, requests } = stubProvider(
460
- [
461
- {
462
- choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
463
- usage: { prompt_tokens: 2, completion_tokens: 1 },
464
- },
465
- ],
466
- { assistantReasoningField: "reasoning_content" },
467
- );
455
+ test("backfills placeholder for a whitespace-only assistant turn", async () => {
456
+ const { provider, requests } = stubProvider([
457
+ {
458
+ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
459
+ usage: { prompt_tokens: 2, completion_tokens: 1 },
460
+ },
461
+ ]);
468
462
 
469
463
  await provider.sendMessage([
470
464
  { role: "user", content: [{ type: "text", text: "question" }] },
471
- {
472
- role: "assistant",
473
- content: [
474
- {
475
- type: "thinking",
476
- thinking: "truncated chain of thought",
477
- signature: "",
478
- },
479
- ],
480
- },
465
+ { role: "assistant", content: [{ type: "text", text: " \n" }] },
481
466
  ]);
482
467
 
483
468
  const params = requests[0] as {
484
469
  messages: Array<{ role: string; content: string | null }>;
485
470
  };
486
471
  const assistantMsg = params.messages.find((m) => m.role === "assistant")!;
487
- // Backfill defaults off, so providers that tolerate null assistant content
488
- // (e.g. Fireworks, Together) are unaffected unless they opt in.
489
- expect(assistantMsg.content).toBeNull();
472
+ // Validators that trim before checking presence treat whitespace-only
473
+ // content as absent, so it needs the same placeholder as empty content.
474
+ expect(assistantMsg.content).toBe(EMPTY_ASSISTANT_TURN_PLACEHOLDER);
490
475
  });
491
476
 
492
477
  test("backfills placeholder when thinking is dropped and no text was emitted", async () => {
493
478
  // Custom openai-compatible endpoints do not set assistantReasoningField, so
494
- // a Stop during thinking serializes to { role: "assistant", content: null }
479
+ // a Stop during thinking serializes to blank content with no tool calls
495
480
  // unless the backfill guard runs.
496
- const { provider, requests } = stubProvider(
497
- [
498
- {
499
- choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
500
- usage: { prompt_tokens: 2, completion_tokens: 1 },
501
- },
502
- ],
503
- { backfillEmptyAssistantContent: true },
504
- );
481
+ const { provider, requests } = stubProvider([
482
+ {
483
+ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
484
+ usage: { prompt_tokens: 2, completion_tokens: 1 },
485
+ },
486
+ ]);
505
487
 
506
488
  await provider.sendMessage([
507
489
  { role: "user", content: [{ type: "text", text: "question" }] },
@@ -534,15 +516,12 @@ describe("OpenAIChatCompletionsProvider reasoning parsing", () => {
534
516
  });
535
517
 
536
518
  test("backfills placeholder for an empty aborted assistant turn", async () => {
537
- const { provider, requests } = stubProvider(
538
- [
539
- {
540
- choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
541
- usage: { prompt_tokens: 2, completion_tokens: 1 },
542
- },
543
- ],
544
- { backfillEmptyAssistantContent: true },
545
- );
519
+ const { provider, requests } = stubProvider([
520
+ {
521
+ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
522
+ usage: { prompt_tokens: 2, completion_tokens: 1 },
523
+ },
524
+ ]);
546
525
 
547
526
  await provider.sendMessage([
548
527
  { role: "user", content: [{ type: "text", text: "question" }] },
@@ -557,15 +536,12 @@ describe("OpenAIChatCompletionsProvider reasoning parsing", () => {
557
536
  });
558
537
 
559
538
  test("does not backfill content when tool calls are present", async () => {
560
- const { provider, requests } = stubProvider(
561
- [
562
- {
563
- choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
564
- usage: { prompt_tokens: 2, completion_tokens: 1 },
565
- },
566
- ],
567
- { backfillEmptyAssistantContent: true },
568
- );
539
+ const { provider, requests } = stubProvider([
540
+ {
541
+ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
542
+ usage: { prompt_tokens: 2, completion_tokens: 1 },
543
+ },
544
+ ]);
569
545
 
570
546
  await provider.sendMessage([
571
547
  {
@@ -992,7 +968,9 @@ describe("thinking-mode tool_choice rejection fallback", () => {
992
968
 
993
969
  expect(requests).toHaveLength(2);
994
970
  expect((requests[0] as { tool_choice?: string }).tool_choice).toBe("none");
995
- expect((requests[1] as { tool_choice?: string }).tool_choice).toBeUndefined();
971
+ expect(
972
+ (requests[1] as { tool_choice?: string }).tool_choice,
973
+ ).toBeUndefined();
996
974
  });
997
975
 
998
976
  test("does not retry a 4xx that does not name tool_choice", async () => {
@@ -1103,9 +1081,13 @@ describe("missing reasoning_content rejection fallback", () => {
1103
1081
  );
1104
1082
  expect(/reasoning_content/i.test(wrapped.message)).toBe(false);
1105
1083
 
1106
- const { provider, requests } = stubProviderWithErrors([wrapped], OK_CHUNKS, {
1107
- assistantReasoningField: "reasoning_content",
1108
- });
1084
+ const { provider, requests } = stubProviderWithErrors(
1085
+ [wrapped],
1086
+ OK_CHUNKS,
1087
+ {
1088
+ assistantReasoningField: "reasoning_content",
1089
+ },
1090
+ );
1109
1091
 
1110
1092
  await provider.sendMessage(toolCallHistory);
1111
1093
 
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import OpenAI from "openai";
4
+
5
+ import { ProviderError } from "../../../util/errors.js";
6
+ import { OpenAIChatCompletionsProvider } from "../chat-completions-provider.js";
7
+
8
+ function providerThatThrows(error: unknown): OpenAIChatCompletionsProvider {
9
+ const provider = new OpenAIChatCompletionsProvider("test-key", "test-model", {
10
+ baseURL: "http://127.0.0.1:1/v1",
11
+ providerName: "openai-compatible",
12
+ providerLabel: "OpenAI-compatible",
13
+ });
14
+ (provider as unknown as { client: unknown }).client = {
15
+ chat: {
16
+ completions: {
17
+ create: async () => {
18
+ throw error;
19
+ },
20
+ },
21
+ },
22
+ };
23
+ return provider;
24
+ }
25
+
26
+ describe("connection-error wrapping", () => {
27
+ test("wraps APIConnectionError with reason network_error and the original as cause", async () => {
28
+ const cause = Object.assign(new Error("connect ECONNREFUSED"), {
29
+ code: "ECONNREFUSED",
30
+ });
31
+ const sdkError = new OpenAI.APIConnectionError({ cause });
32
+ const thrown = await providerThatThrows(sdkError)
33
+ .sendMessage([{ role: "user", content: [{ type: "text", text: "hi" }] }])
34
+ .then(
35
+ () => null,
36
+ (e: unknown) => e,
37
+ );
38
+ expect(thrown).toBeInstanceOf(ProviderError);
39
+ const providerError = thrown as ProviderError;
40
+ expect(providerError.reason).toBe("network_error");
41
+ expect(providerError.statusCode).toBeUndefined();
42
+ expect(providerError.cause).toBe(sdkError);
43
+ });
44
+ });
@@ -145,7 +145,7 @@ const ID_SHAPES = [
145
145
  ] as const;
146
146
 
147
147
  /** History whose tool_result is paired with a preceding tool_use. */
148
- function pairedHistory(id: string): Message[] {
148
+ function pairedHistory(id: string, result = "file contents"): Message[] {
149
149
  return [
150
150
  { role: "user", content: [{ type: "text", text: "Read the file" }] },
151
151
  {
@@ -165,7 +165,7 @@ function pairedHistory(id: string): Message[] {
165
165
  {
166
166
  type: "tool_result",
167
167
  tool_use_id: id,
168
- content: "file contents",
168
+ content: result,
169
169
  },
170
170
  ],
171
171
  },
@@ -383,6 +383,38 @@ describe("OpenAIChatCompletionsProvider orphan tool_result guard", () => {
383
383
  },
384
384
  ]);
385
385
  });
386
+
387
+ test("keeps JSON Schema references opaque for Gemini-compatible gateways", async () => {
388
+ const provider = makeProvider();
389
+ const stub = stubChatCreate(provider);
390
+ const schemaResult = JSON.stringify({
391
+ $defs: { LLMProvider: { type: "string" } },
392
+ properties: {
393
+ provider: { $ref: "#/$defs/LLMProvider" },
394
+ },
395
+ });
396
+
397
+ await provider.sendMessage(pairedHistory("call_schema", schemaResult));
398
+
399
+ const sent = stub.messages();
400
+ const toolMessage = sent.find((message) => message.role === "tool");
401
+ expect(toolMessage).toBeDefined();
402
+ expect(JSON.parse(toolMessage!.content as string)).toEqual({
403
+ output: schemaResult,
404
+ });
405
+ });
406
+
407
+ test("leaves ordinary JSON tool results unchanged", async () => {
408
+ const provider = makeProvider();
409
+ const stub = stubChatCreate(provider);
410
+ const ordinaryResult = JSON.stringify({ status: "ok", count: 3 });
411
+
412
+ await provider.sendMessage(pairedHistory("call_json", ordinaryResult));
413
+
414
+ const sent = stub.messages();
415
+ const toolMessage = sent.find((message) => message.role === "tool");
416
+ expect(toolMessage?.content).toBe(ordinaryResult);
417
+ });
386
418
  });
387
419
 
388
420
  // ---------------------------------------------------------------------------
@@ -1,4 +1,4 @@
1
- import type OpenAI from "openai";
1
+ import OpenAI from "openai";
2
2
 
3
3
  import type { ProviderErrorReason } from "../../util/errors.js";
4
4
  import {
@@ -238,7 +238,21 @@ export function normalizeOpenAIAPIError(
238
238
  if (rawBody) {
239
239
  out.rawBody = rawBody;
240
240
  }
241
- out.reason = deriveReason(out, error.status);
241
+ // Transport failures never reached the server, so there is no status or
242
+ // body for deriveReason to read; the SDK's error class is the only signal.
243
+ // APIUserAbortError (caller cancellation, inner stream deadline) is a
244
+ // sibling class, not a subclass, so it deliberately does NOT match here:
245
+ // classifying it as a transient network error would make the retry loop
246
+ // re-run 30-minute deadline failures. Guarded because test doubles of the
247
+ // openai module may not define the class.
248
+ const connectionErrorClass = OpenAI.APIConnectionError as
249
+ | typeof OpenAI.APIConnectionError
250
+ | undefined;
251
+ out.reason =
252
+ typeof connectionErrorClass === "function" &&
253
+ error instanceof connectionErrorClass
254
+ ? "network_error"
255
+ : deriveReason(out, error.status);
242
256
  return out;
243
257
  }
244
258
 
@@ -121,14 +121,15 @@ export function detectVisionNotSupported(
121
121
 
122
122
  /**
123
123
  * Fallback `content` for an assistant turn that has neither visible text nor
124
- * tool calls (e.g. a reasoning-only turn truncated at the output-token limit).
124
+ * tool calls (e.g. a reasoning-only turn truncated at the output-token limit,
125
+ * or a Stop mid-stream before any text).
125
126
  *
126
127
  * The OpenAI chat-completions schema requires an assistant message to carry
127
128
  * `content` or `tool_calls`. OpenAI itself tolerates `content: null`/`""` here,
128
- * but strict OpenAI-compatible backends do not: DeepSeek via OpenRouter rejects
129
- * the request with `Invalid assistant message: content or tool_calls must be
130
- * set`, and vLLM-style validators coerce empty-string content back to null and
131
- * reject it the same way. The placeholder must therefore be a non-empty string.
129
+ * but strict OpenAI-compatible backends do not: DeepSeek rejects the request
130
+ * with `Invalid assistant message: content or tool_calls must be set`, and
131
+ * vLLM-style validators coerce empty-string content back to null and reject it
132
+ * the same way. The placeholder must therefore be a non-empty string.
132
133
  *
133
134
  * We reuse the shared empty-turn sentinel so that
134
135
  * `isPlaceholderSentinelText`/`cleanAssistantContent` strip it from persisted
@@ -168,14 +169,6 @@ export interface OpenAIChatCompletionsProviderOptions {
168
169
  * tool-call turns. DeepSeek thinking mode that requires the field even when
169
170
  * empty is handled by a one-shot retry. */
170
171
  assistantReasoningField?: "reasoning" | "reasoning_content";
171
- /** Backfill a non-empty placeholder for assistant turns that would otherwise
172
- * serialize with neither `content` nor `tool_calls` (e.g. reasoning-only
173
- * turns, or a Stop mid-stream before any text). Off by default; enabled for
174
- * OpenRouter, Vercel AI Gateway, LiteLLM, and custom `openai-compatible`
175
- * endpoints, whose downstream providers (e.g. DeepSeek, vLLM, Portkey)
176
- * reject such messages with `Invalid assistant message: content or
177
- * tool_calls must be set`. See {@link EMPTY_ASSISTANT_TURN_PLACEHOLDER}. */
178
- backfillEmptyAssistantContent?: boolean;
179
172
  /** Present object-typed tool params to the model as JSON-string params and
180
173
  * decode them back to objects on the response. Works around models whose
181
174
  * function-call serialization collapses nested objects to `{}` (observed
@@ -248,6 +241,60 @@ function isClientErrorStatus(error: unknown): boolean {
248
241
  return typeof status === "number" && status >= 400 && status < 500;
249
242
  }
250
243
 
244
+ /**
245
+ * True when a parsed tool result contains a local JSON Schema reference.
246
+ *
247
+ * Gemini reserves `$ref` fields inside structured function responses for
248
+ * multimodal part references. OpenAI-compatible gateways can JSON-decode tool
249
+ * message content before translating it to Gemini, which makes a JSON Schema
250
+ * result look like one of those references and causes an INVALID_ARGUMENT.
251
+ */
252
+ function containsLocalJsonSchemaReference(value: unknown): boolean {
253
+ const pending: unknown[] = [value];
254
+ while (pending.length > 0) {
255
+ const current = pending.pop();
256
+ if (Array.isArray(current)) {
257
+ pending.push(...current);
258
+ continue;
259
+ }
260
+ if (current === null || typeof current !== "object") {
261
+ continue;
262
+ }
263
+
264
+ const record = current as Record<string, unknown>;
265
+ const ref = record.$ref;
266
+ if (typeof ref === "string" && (ref === "#" || ref.startsWith("#/"))) {
267
+ return true;
268
+ }
269
+ pending.push(...Object.values(record));
270
+ }
271
+ return false;
272
+ }
273
+
274
+ /**
275
+ * Keep JSON Schema tool results opaque across OpenAI-compatible gateways.
276
+ *
277
+ * The outer object is intentionally valid JSON so gateways that decode tool
278
+ * content produce `{ output: string }`; the schema's `$ref` remains inside the
279
+ * string and cannot be interpreted as a Gemini multimodal part reference.
280
+ */
281
+ function protectJsonSchemaToolResult(payload: string): string {
282
+ if (!payload.includes('"$ref"')) {
283
+ return payload;
284
+ }
285
+
286
+ let parsed: unknown;
287
+ try {
288
+ parsed = JSON.parse(payload);
289
+ } catch {
290
+ return payload;
291
+ }
292
+
293
+ return containsLocalJsonSchemaReference(parsed)
294
+ ? JSON.stringify({ output: payload })
295
+ : payload;
296
+ }
297
+
251
298
  /**
252
299
  * True when the request carried an explicit reasoning opt-out (`"none"` sent
253
300
  * as flat `reasoning_effort` or nested `reasoning.effort`) and the provider
@@ -556,7 +603,6 @@ export class OpenAIChatCompletionsProvider implements Provider {
556
603
  | "reasoning"
557
604
  | "reasoning_content"
558
605
  | undefined;
559
- private backfillEmptyAssistantContent: boolean;
560
606
  private coerceObjectArgsToJsonString: boolean;
561
607
  private omitToolChoiceWhenReasoning: boolean;
562
608
 
@@ -584,8 +630,6 @@ export class OpenAIChatCompletionsProvider implements Provider {
584
630
  this.requestHeaders = options.requestHeaders ?? {};
585
631
  this.parseThinkTags = options.parseThinkTags ?? false;
586
632
  this.assistantReasoningField = options.assistantReasoningField;
587
- this.backfillEmptyAssistantContent =
588
- options.backfillEmptyAssistantContent ?? false;
589
633
  this.coerceObjectArgsToJsonString =
590
634
  options.coerceObjectArgsToJsonString ?? false;
591
635
  this.omitToolChoiceWhenReasoning =
@@ -709,8 +753,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
709
753
  if (toolChoice !== undefined) {
710
754
  const thinkingOn = isThinkingEnabledOnWire(params);
711
755
  const skipAutoDefault = thinkingOn && toolChoice === "auto";
712
- const skipAllChoices =
713
- thinkingOn && this.omitToolChoiceWhenReasoning;
756
+ const skipAllChoices = thinkingOn && this.omitToolChoiceWhenReasoning;
714
757
  if (!skipAutoDefault && !skipAllChoices) {
715
758
  params.tool_choice = toolChoice;
716
759
  }
@@ -1147,7 +1190,10 @@ export class OpenAIChatCompletionsProvider implements Provider {
1147
1190
  );
1148
1191
  }
1149
1192
  const retryAfterMs = extractRetryAfterMs(error.headers);
1193
+ // `cause` keeps the SDK error's errno-bearing chain reachable for
1194
+ // network-shape classification (`isRetryableNetworkError` walks it).
1150
1195
  const errorOptions: {
1196
+ cause?: unknown;
1151
1197
  retryAfterMs?: number;
1152
1198
  abortReason?: unknown;
1153
1199
  apiErrorCode?: string;
@@ -1156,7 +1202,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
1156
1202
  requestId?: string;
1157
1203
  rawBody?: string;
1158
1204
  reason?: ProviderErrorReason;
1159
- } = {};
1205
+ } = { cause: error };
1160
1206
  if (retryAfterMs !== undefined) {
1161
1207
  errorOptions.retryAfterMs = retryAfterMs;
1162
1208
  }
@@ -1185,7 +1231,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
1185
1231
  formattedMessage,
1186
1232
  this.name,
1187
1233
  error.status,
1188
- Object.keys(errorOptions).length > 0 ? errorOptions : undefined,
1234
+ errorOptions,
1189
1235
  );
1190
1236
  }
1191
1237
  throw new ProviderError(
@@ -1300,7 +1346,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
1300
1346
  result.push({
1301
1347
  role: "tool",
1302
1348
  tool_call_id: tr.tool_use_id,
1303
- content: serialized.payload,
1349
+ content: protectJsonSchemaToolResult(serialized.payload),
1304
1350
  });
1305
1351
  }
1306
1352
 
@@ -1387,16 +1433,16 @@ export class OpenAIChatCompletionsProvider implements Provider {
1387
1433
  }
1388
1434
 
1389
1435
  // An assistant message must carry `content` or `tool_calls`. A turn with
1390
- // neither (e.g. reasoning-only, or a Stop before any text) would serialize
1391
- // to null/empty content with no tool calls, which strict OpenAI-compatible
1392
- // backends reject. Reasoning lives in a separate field and does not
1393
- // satisfy this constraint. Scoped to providers that need it (OpenRouter,
1394
- // Vercel AI Gateway, LiteLLM, openai-compatible) via
1395
- // `backfillEmptyAssistantContent`.
1436
+ // neither (e.g. reasoning-only, a Stop before any text, or a turn whose
1437
+ // text arrived as whitespace) would serialize to blank content with no
1438
+ // tool calls, which strict OpenAI-compatible backends reject. Reasoning
1439
+ // lives in a separate field and does not satisfy the constraint, and
1440
+ // whitespace-only content does not survive a validator that trims before
1441
+ // checking presence, so the placeholder covers both.
1396
1442
  if (
1397
- this.backfillEmptyAssistantContent &&
1398
1443
  !result.tool_calls &&
1399
- (result.content === null || result.content === "")
1444
+ (result.content === null ||
1445
+ (typeof result.content === "string" && result.content.trim() === ""))
1400
1446
  ) {
1401
1447
  result.content = EMPTY_ASSISTANT_TURN_PLACEHOLDER;
1402
1448
  }
@@ -680,7 +680,10 @@ export class OpenAIResponsesProvider implements Provider {
680
680
  });
681
681
  }
682
682
  const retryAfterMs = extractRetryAfterMs(error.headers);
683
+ // `cause` keeps the SDK error's errno-bearing chain reachable for
684
+ // network-shape classification (`isRetryableNetworkError` walks it).
683
685
  const errorOptions: {
686
+ cause?: unknown;
684
687
  retryAfterMs?: number;
685
688
  abortReason?: unknown;
686
689
  apiErrorCode?: string;
@@ -689,7 +692,7 @@ export class OpenAIResponsesProvider implements Provider {
689
692
  requestId?: string;
690
693
  rawBody?: string;
691
694
  reason?: ProviderErrorReason;
692
- } = {};
695
+ } = { cause: error };
693
696
  if (retryAfterMs !== undefined) {
694
697
  errorOptions.retryAfterMs = retryAfterMs;
695
698
  }
@@ -718,7 +721,7 @@ export class OpenAIResponsesProvider implements Provider {
718
721
  formattedMessage,
719
722
  this.name,
720
723
  error.status,
721
- Object.keys(errorOptions).length > 0 ? errorOptions : undefined,
724
+ errorOptions,
722
725
  );
723
726
  }
724
727
  throw new ProviderError(
@@ -143,7 +143,6 @@ export class OpenRouterProvider extends OpenAIChatCompletionsProvider {
143
143
  streamTimeoutMs: options.streamTimeoutMs,
144
144
  requestHeaders: OPENROUTER_APP_ATTRIBUTION_HEADERS,
145
145
  assistantReasoningField: "reasoning",
146
- backfillEmptyAssistantContent: true,
147
146
  });
148
147
  this.openRouterApiKey = apiKey;
149
148
  this.resolvedBaseURL = baseURL;
@@ -11,6 +11,7 @@ import {
11
11
  import { getConfig } from "../config/loader.js";
12
12
  import type { LLMCallSite } from "../config/schemas/llm.js";
13
13
  import { getDb } from "../persistence/db-connection.js";
14
+ import type { ProviderRouteAttribution } from "../util/errors.js";
14
15
  import { getLogger } from "../util/logger.js";
15
16
  import {
16
17
  describeSubscriptionModelIncompatibility,
@@ -70,6 +71,16 @@ export class CallSiteConfiguredProvider implements Provider {
70
71
  // (callers like the advisor consult gate on it). Fixed at construction.
71
72
  public readonly supportsNativeWebSearch?: boolean;
72
73
 
74
+ /**
75
+ * Route the inner adapter signs with, stamped at connection resolution.
76
+ * Forwarded live (not snapshotted) so consumers holding this wrapper —
77
+ * e.g. the save-time probe's billing gate — read the row dispatch
78
+ * actually selected.
79
+ */
80
+ get routeAttribution(): ProviderRouteAttribution | undefined {
81
+ return this.inner.routeAttribution;
82
+ }
83
+
73
84
  constructor(
74
85
  private readonly inner: Provider,
75
86
  private readonly callSite: LLMCallSite,
@@ -246,6 +246,12 @@ const RETRYABLE_PROVIDER_ERROR_REASONS = new Set<ProviderErrorReason>([
246
246
  "rate_limited",
247
247
  "overloaded",
248
248
  "server_error",
249
+ // Transport failures that never reached the server (SDK connection
250
+ // errors, Gemini proxy interception). Deadline and cancellation shapes
251
+ // never carry this reason — they surface as reason-less aborts and
252
+ // short-circuit in isRetryableError before the reason check — so a
253
+ // 30-minute stream deadline failure is never retried through it.
254
+ "network_error",
249
255
  ]);
250
256
 
251
257
  function isRetryableStreamError(error: unknown): boolean {
@@ -76,6 +76,14 @@ export interface SearchProviderCatalogEntry {
76
76
  /** Privacy-policy URL surfaced in marketing data-sharing docs.
77
77
  * BYOK providers only. */
78
78
  readonly privacyPolicyUrl?: string;
79
+ /**
80
+ * When true, settings UIs show an optional API Base field. Empty /
81
+ * omitted base uses {@link defaultApiBase}.
82
+ */
83
+ readonly supportsApiBase?: boolean;
84
+ /** Cloud default origin when `supportsApiBase` is true and the user
85
+ * leaves API Base empty. */
86
+ readonly defaultApiBase?: string;
79
87
  }
80
88
 
81
89
  export const SEARCH_PROVIDER_CATALOG: readonly SearchProviderCatalogEntry[] = [
@@ -143,6 +151,18 @@ export const SEARCH_PROVIDER_CATALOG: readonly SearchProviderCatalogEntry[] = [
143
151
  fallbackOrder: 5,
144
152
  privacyPolicyUrl: "https://keenable.ai/privacy",
145
153
  },
154
+ {
155
+ id: "fastcrw",
156
+ displayName: "fastCRW",
157
+ kind: "byok",
158
+ apiKeyPrefix: "crw_live_...",
159
+ envVar: "FASTCRW_API_KEY",
160
+ secretKey: "fastcrw",
161
+ fallbackOrder: 6,
162
+ privacyPolicyUrl: "https://fastcrw.com/privacy",
163
+ supportsApiBase: true,
164
+ defaultApiBase: "https://api.fastcrw.com",
165
+ },
146
166
  ];
147
167
 
148
168
  /** Provider ids accepted by the web-search config schema. */
@@ -46,7 +46,6 @@ export class VercelAIGatewayProvider extends OpenAIChatCompletionsProvider {
46
46
  // Vercel's OpenAI-compat endpoint streams reasoning text in
47
47
  // `delta.reasoning` (see its Advanced Configuration docs).
48
48
  assistantReasoningField: "reasoning",
49
- backfillEmptyAssistantContent: true,
50
49
  });
51
50
  this.gatewayApiKey = apiKey;
52
51
  this.resolvedBaseURL = baseURL;
@@ -74,6 +74,7 @@ Host CU allows the assistant to proxy computer-use actions (screenshots, mouse/k
74
74
  - **Resolution**: Clients execute the CU action on the host and respond via:
75
75
  - `POST /v1/host-cu-result` — `{ requestId, axTree?, axDiff?, screenshot?, screenshotWidthPx?, screenshotHeightPx?, screenWidthPt?, screenHeightPt?, executionResult?, executionError?, secondaryWindows?, userGuidance? }`
76
76
  - **Tracking**: Uses the same `pending-interactions` tracker as the other host proxy types, with `kind: "host_cu"`. Registration happens in `conversation-routes.ts` and the route handler is in `host-cu-routes.ts`.
77
+ - **Conversation-agnostic observation**: `observeHostScreen()` in `host-observe.ts` issues a `computer_use_observe` request with no conversation, for callers outside an agent turn (`HostCuProxy` only exists inside one). It takes the initiating actor's principal id and reaches only that actor's own `host_cu` clients: `pickSameUserAutoResolve` picks the default target and `enforceSameActorOrErrorResult` gates an explicitly named one, both before the request is registered or broadcast. Its pending interaction carries no `conversationId`, so `host-cu-routes.ts` hands the raw observation fields straight to the waiting caller instead of routing through a conversation's CU proxy. On the desktop side the ordinary `host_cu` executor services the request.
77
78
 
78
79
  ### Host browser (desktop proxy CDP execution)
79
80