@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
@@ -15,6 +15,7 @@
15
15
  import type { Command } from "commander";
16
16
 
17
17
  import { cliIpcCall, exitFromIpcResult } from "../../../ipc/cli-client.js";
18
+ import type { ChannelReadinessSnapshot } from "../../../runtime/channel-readiness-types.js";
18
19
  import { applyCommandHelp, subcommand } from "../../lib/cli-command-help.js";
19
20
  import { registerCommand } from "../../lib/register-command.js";
20
21
  import { log } from "../../logger.js";
@@ -22,26 +23,16 @@ import { shouldOutputJson, writeOutput } from "../../output.js";
22
23
  import { channelsHelp } from "./index.help.js";
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
- // Snapshot shape (mirrors runtime/routes/channel-readiness-routes.ts)
26
+ // Snapshot shape
26
27
  // ---------------------------------------------------------------------------
27
28
 
28
- interface ReadinessCheck {
29
- name: string;
30
- passed: boolean;
31
- message: string;
32
- }
33
-
34
- interface ChannelSnapshot {
35
- channel: string;
36
- ready: boolean;
37
- setupStatus: "not_configured" | "incomplete" | "ready";
38
- checkedAt: number;
39
- stale: boolean;
40
- reasons: Array<{ code: string; text: string }>;
41
- localChecks: ReadinessCheck[];
42
- remoteChecks?: ReadinessCheck[];
43
- channelHandle?: unknown;
44
- }
29
+ /**
30
+ * The readiness service's own snapshot type, not a copy of it. A local
31
+ * mirror is what let this command fall a field behind the contract: it had
32
+ * no `health`, so a channel whose setup was complete and whose delivery had
33
+ * stopped rendered as unfinished setup.
34
+ */
35
+ type ChannelSnapshot = ChannelReadinessSnapshot;
45
36
 
46
37
  interface ReadinessResponse {
47
38
  success: boolean;
@@ -52,26 +43,49 @@ interface ReadinessResponse {
52
43
  // Rendering
53
44
  // ---------------------------------------------------------------------------
54
45
 
55
- function statusGlyph(s: ChannelSnapshot): string {
56
- if (s.ready) {
57
- return "✅";
58
- }
59
- if (s.setupStatus === "not_configured") {
60
- return "○ ";
61
- }
62
- return "⚠️ ";
63
- }
64
-
65
- function statusWord(s: ChannelSnapshot): string {
46
+ /**
47
+ * Two axes, reported as one line each.
48
+ *
49
+ * `setupStatus` says whether setup finished; `health` says whether the
50
+ * channel is delivering. Collapsing them into setup progress alone is how a
51
+ * configured channel whose socket had died came to print as `incomplete`,
52
+ * which tells the reader to go finish a setup that is already complete.
53
+ * Ordered so the operational answer wins: once setup is done, what remains
54
+ * to say is whether it works.
55
+ */
56
+ function statusState(
57
+ s: ChannelSnapshot,
58
+ ):
59
+ | "ready"
60
+ | "not configured"
61
+ | "not delivering"
62
+ | "state unknown"
63
+ | "incomplete" {
66
64
  if (s.ready) {
67
65
  return "ready";
68
66
  }
69
67
  if (s.setupStatus === "not_configured") {
70
68
  return "not configured";
71
69
  }
70
+ if (s.setupStatus === "ready") {
71
+ return s.health === "unknown" ? "state unknown" : "not delivering";
72
+ }
72
73
  return "incomplete";
73
74
  }
74
75
 
76
+ const STATE_GLYPHS: Record<ReturnType<typeof statusState>, string> = {
77
+ ready: "✅",
78
+ "not configured": "○ ",
79
+ "not delivering": "⚠️ ",
80
+ // Neither vouched for nor condemned: the checks ran and settled nothing.
81
+ "state unknown": "? ",
82
+ incomplete: "⚠️ ",
83
+ };
84
+
85
+ function statusGlyph(s: ChannelSnapshot): string {
86
+ return STATE_GLYPHS[statusState(s)];
87
+ }
88
+
75
89
  function renderList(snapshots: ChannelSnapshot[]): void {
76
90
  const sorted = [...snapshots].sort((a, b) =>
77
91
  a.channel.localeCompare(b.channel),
@@ -79,12 +93,12 @@ function renderList(snapshots: ChannelSnapshot[]): void {
79
93
  log.info("Channel Status");
80
94
  log.info("------------- ------");
81
95
  for (const s of sorted) {
82
- log.info(`${statusGlyph(s)} ${s.channel.padEnd(12)} ${statusWord(s)}`);
96
+ log.info(`${statusGlyph(s)} ${s.channel.padEnd(12)} ${statusState(s)}`);
83
97
  }
84
98
  }
85
99
 
86
100
  function renderSnapshot(s: ChannelSnapshot): void {
87
- log.info(`${statusGlyph(s)} ${s.channel} — ${s.setupStatus}`);
101
+ log.info(`${statusGlyph(s)} ${s.channel} — ${statusState(s)}`);
88
102
  if (s.reasons.length > 0) {
89
103
  log.info("");
90
104
  log.info("Reasons:");
@@ -28,6 +28,8 @@ interface ProfileSummary {
28
28
  source: "managed" | "user";
29
29
  provider_connection?: string;
30
30
  availability: { status: string; message?: string } | null;
31
+ /** Static problem with the stored entry itself; absent when it checks out. */
32
+ config_issue?: { code: string; message: string };
31
33
  }
32
34
 
33
35
  interface ProfileWriteResult {
@@ -37,6 +39,29 @@ interface ProfileWriteResult {
37
39
  warnings: string[];
38
40
  /** Live-call command the daemon suggests for verifying the written profile. */
39
41
  verify?: string;
42
+ /** Save-time probe verdict (null = no verdict); absent when the daemon predates the route. */
43
+ check?: ProfileCheck | null;
44
+ }
45
+
46
+ interface ProfileCheck {
47
+ ok: boolean;
48
+ blame?: string;
49
+ message?: string;
50
+ }
51
+
52
+ /**
53
+ * Probe the just-written profile with one minimal request through the
54
+ * daemon's validate route. Advisory: any transport or route error (e.g. a
55
+ * daemon predating the route) yields undefined and the save flow proceeds.
56
+ */
57
+ async function fetchProfileCheck(
58
+ name: string,
59
+ ): Promise<ProfileCheck | null | undefined> {
60
+ const probe = await cliIpcCall<{ check: ProfileCheck | null }>(
61
+ "inference_profiles_validate",
62
+ { pathParams: { name } },
63
+ );
64
+ return probe.ok ? probe.result!.check : undefined;
40
65
  }
41
66
 
42
67
  type WriteFlags = {
@@ -123,6 +148,9 @@ function printWriteResult(
123
148
  for (const warning of result.warnings) {
124
149
  writeLine(`warning: ${warning}`);
125
150
  }
151
+ if (result.check && !result.check.ok && result.check.message) {
152
+ writeLine(`warning: ${result.check.message}`);
153
+ }
126
154
  writeLine(`profile ${result.name} ${verb}`);
127
155
  if (result.verify) {
128
156
  writeLine(`Verify it works: ${result.verify}`);
@@ -152,7 +180,16 @@ export function attachProfilesSubcommand(inference: Command): void {
152
180
  return;
153
181
  }
154
182
  renderTable(
155
- ["NAME", "LABEL", "PROVIDER", "MODEL", "STATUS", "SOURCE", "AVAIL"],
183
+ [
184
+ "NAME",
185
+ "LABEL",
186
+ "PROVIDER",
187
+ "MODEL",
188
+ "STATUS",
189
+ "SOURCE",
190
+ "AVAIL",
191
+ "CONFIG",
192
+ ],
156
193
  rows.map((p) => [
157
194
  p.name,
158
195
  p.label ?? "-",
@@ -161,6 +198,7 @@ export function attachProfilesSubcommand(inference: Command): void {
161
198
  p.status,
162
199
  p.source,
163
200
  p.availability ? p.availability.status : "-",
201
+ p.config_issue ? p.config_issue.code : "ok",
164
202
  ]),
165
203
  );
166
204
  });
@@ -172,6 +210,7 @@ export function attachProfilesSubcommand(inference: Command): void {
172
210
  name: string;
173
211
  entry: Record<string, unknown>;
174
212
  availability: { status: string; message?: string } | null;
213
+ config_issue?: { code: string; message: string };
175
214
  }>("inference_profiles_get", { pathParams: { name } });
176
215
  if (!ipcResult.ok) {
177
216
  writeCliError(ipcResult.error ?? "Unknown error", opts.json);
@@ -192,6 +231,10 @@ export function attachProfilesSubcommand(inference: Command): void {
192
231
  writeLine(` ${result.availability.message}`);
193
232
  }
194
233
  }
234
+ if (result.config_issue) {
235
+ writeLine(` config: ${result.config_issue.code}`);
236
+ writeLine(` ${result.config_issue.message}`);
237
+ }
195
238
  },
196
239
  );
197
240
 
@@ -219,7 +262,12 @@ export function attachProfilesSubcommand(inference: Command): void {
219
262
  writeCliError(ipcResult.error ?? "Unknown error", opts.json);
220
263
  return;
221
264
  }
222
- printWriteResult("created", ipcResult.result!, opts.json);
265
+ const check = await fetchProfileCheck(name);
266
+ printWriteResult(
267
+ "created",
268
+ { ...ipcResult.result!, ...(check !== undefined ? { check } : {}) },
269
+ opts.json,
270
+ );
223
271
  },
224
272
  );
225
273
 
@@ -246,7 +294,12 @@ export function attachProfilesSubcommand(inference: Command): void {
246
294
  writeCliError(ipcResult.error ?? "Unknown error", opts.json);
247
295
  return;
248
296
  }
249
- printWriteResult("updated", ipcResult.result!, opts.json);
297
+ const check = await fetchProfileCheck(name);
298
+ printWriteResult(
299
+ "updated",
300
+ { ...ipcResult.result!, ...(check !== undefined ? { check } : {}) },
301
+ opts.json,
302
+ );
250
303
  },
251
304
  );
252
305
 
@@ -23,6 +23,7 @@ import { cliIpcCall } from "../../ipc/cli-client.js";
23
23
  import type { OAuth2Config } from "../../security/oauth2.js";
24
24
  import { subcommand } from "../lib/cli-command-help.js";
25
25
  import { writeCliError } from "../lib/cli-output.js";
26
+ import { openInHostBrowser } from "../lib/open-browser.js";
26
27
  import { attachDefaultProviderSubcommand } from "./inference-providers-default.js";
27
28
 
28
29
  // ---------------------------------------------------------------------------
@@ -41,6 +42,22 @@ interface ProviderConnection {
41
42
  baseUrl?: string | null;
42
43
  createdAt: string;
43
44
  updatedAt: string;
45
+ /** Save-time probe of a custom base URL; advisory, never blocks the save. */
46
+ endpoint_check?: {
47
+ ok: boolean;
48
+ status?: number;
49
+ resolved_url: string;
50
+ hint?: string;
51
+ };
52
+ }
53
+
54
+ /** Human-mode warning line for a failed save-time endpoint probe. */
55
+ function formatEndpointCheckWarning(conn: ProviderConnection): string {
56
+ const check = conn.endpoint_check;
57
+ if (!check || check.ok) {
58
+ return "";
59
+ }
60
+ return `Warning: ${check.hint ?? `The endpoint returned HTTP ${check.status} for a test request.`} (probed ${check.resolved_url})\n`;
44
61
  }
45
62
 
46
63
  // ---------------------------------------------------------------------------
@@ -304,6 +321,7 @@ function attachCreateSubcommand(parent: Command): void {
304
321
  } else {
305
322
  process.stdout.write(
306
323
  `Added provider "${conn.name}" (provider=${conn.provider})\n` +
324
+ formatEndpointCheckWarning(conn) +
307
325
  `Verify it works: point a profile at "${conn.name}", then run: assistant inference send --profile <profile> "Reply with OK"\n`,
308
326
  );
309
327
  }
@@ -394,6 +412,7 @@ function attachUpdateSubcommand(parent: Command): void {
394
412
  } else {
395
413
  process.stdout.write(
396
414
  `Updated provider "${name}" (auth=${formatAuth(conn.auth)})\n` +
415
+ formatEndpointCheckWarning(conn) +
397
416
  `Verify it works: assistant inference send --profile <profile-using-this-provider> "Reply with OK"\n`,
398
417
  );
399
418
  }
@@ -456,12 +475,19 @@ function attachLoginChatgptSubcommand(providers: Command): void {
456
475
  import("../../security/secure-keys.js"),
457
476
  ]);
458
477
  // Step 1: Run browser-based PKCE OAuth flow
459
- process.stdout.write("Opening browser for ChatGPT authentication...\n");
478
+ if (!opts.json) {
479
+ process.stdout.write(
480
+ "Opening browser for ChatGPT authentication...\n",
481
+ );
482
+ }
460
483
  const result = await startOAuth2Flow(
461
484
  OPENAI_CODEX_OAUTH_CONFIG,
462
485
  {
463
486
  openUrl: (url) => {
464
- Bun.spawn(["open", url]);
487
+ openInHostBrowser(url);
488
+ const fallbackMessage = `If the browser did not open, visit:\n${url}\n`;
489
+ const output = opts.json ? process.stderr : process.stdout;
490
+ output.write(fallbackMessage);
465
491
  },
466
492
  },
467
493
  {
@@ -776,6 +776,11 @@ the same provider will resolve against the wrong host or path and fail (often
776
776
  with an opaque HTML 404). When in doubt, pass an absolute URL: it is the safe
777
777
  form for any service other than the provider's default.
778
778
 
779
+ The body (-d) is JSON by default. Give a non-JSON Content-Type and the body
780
+ travels to the provider exactly as written, which is what multipart, XML, and
781
+ form-encoded payloads need. Body files (-d @<path>) are read as UTF-8 text, so
782
+ a payload that is not valid UTF-8 cannot be sent this way.
783
+
779
784
  Note: The Authorization header is set automatically. User-supplied
780
785
  -H "Authorization: ..." will be overridden by the OAuth bearer token.
781
786
 
@@ -784,7 +789,8 @@ Examples:
784
789
  $ assistant oauth request --provider google /gmail/v1/users/me/messages -G
785
790
  $ assistant oauth request --provider twitter -X POST -d '{"text":"Hello"}' https://api.x.com/2/tweets
786
791
  $ assistant oauth request --provider google https://www.googleapis.com/calendar/v3/calendars/primary/events
787
- $ assistant oauth request --provider slack -H "Content-Type: application/json" -d '{"channel":"C123"}' /api/chat.postMessage --json`,
792
+ $ assistant oauth request --provider slack -H "Content-Type: application/json" -d '{"channel":"C123"}' /api/chat.postMessage --json
793
+ $ assistant oauth request --provider google -X POST -H "Content-Type: multipart/related; boundary=b" -d @upload.txt "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart"`,
788
794
  },
789
795
  {
790
796
  name: "disconnect",
@@ -0,0 +1,290 @@
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
5
+
6
+ const PNG_MAGIC = Buffer.from([
7
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe, 0x00,
8
+ ]);
9
+
10
+ const ipcCalls: string[] = [];
11
+ const handleRequestCalls: unknown[] = [];
12
+
13
+ let handleRequestResult: {
14
+ ok: boolean;
15
+ status: number;
16
+ headers: Record<string, string>;
17
+ body: unknown;
18
+ bodyEncoding?: "base64";
19
+ account?: string | null;
20
+ } = {
21
+ ok: true,
22
+ status: 200,
23
+ headers: { "content-type": "application/json" },
24
+ body: { hello: "world" },
25
+ account: "user@example.com",
26
+ };
27
+
28
+ mock.module("../../../ipc/cli-client.js", () => ({
29
+ cliIpcCall: async (method: string) => {
30
+ ipcCalls.push(method);
31
+ return { ok: false, error: `Unexpected IPC method ${method}` };
32
+ },
33
+ exitFromIpcResult: (r: { error?: string }) => {
34
+ throw new Error(r.error ?? "IPC error");
35
+ },
36
+ }));
37
+
38
+ mock.module("../../../runtime/routes/oauth-commands-routes.js", () => ({
39
+ handleRequest: async (args: unknown) => {
40
+ handleRequestCalls.push(args);
41
+ return handleRequestResult;
42
+ },
43
+ }));
44
+
45
+ import { Command } from "commander";
46
+
47
+ import { applyCommandHelp } from "../../lib/cli-command-help.js";
48
+ import { oauthHelp } from "./index.help.js";
49
+ import { readBodyData, registerRequestCommand } from "./request.js";
50
+
51
+ let tempDir: string;
52
+
53
+ beforeEach(() => {
54
+ ipcCalls.length = 0;
55
+ handleRequestCalls.length = 0;
56
+ process.exitCode = 0;
57
+ tempDir = mkdtempSync(join(tmpdir(), "oauth-request-"));
58
+ handleRequestResult = {
59
+ ok: true,
60
+ status: 200,
61
+ headers: { "content-type": "application/json" },
62
+ body: { hello: "world" },
63
+ account: "user@example.com",
64
+ };
65
+ });
66
+
67
+ afterEach(() => {
68
+ rmSync(tempDir, { recursive: true, force: true });
69
+ process.exitCode = 0;
70
+ });
71
+
72
+ async function runRequestCommand(args: string[]): Promise<{
73
+ stdout: Buffer;
74
+ exitCode: number;
75
+ }> {
76
+ const chunks: Buffer[] = [];
77
+ const originalWrite = process.stdout.write;
78
+ process.stdout.write = ((chunk: string | Uint8Array) => {
79
+ chunks.push(
80
+ typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk),
81
+ );
82
+ return true;
83
+ }) as typeof process.stdout.write;
84
+
85
+ try {
86
+ const program = new Command();
87
+ program.exitOverride();
88
+ const oauth = program.command("oauth").description(oauthHelp.description);
89
+ applyCommandHelp(oauth, oauthHelp);
90
+ registerRequestCommand(oauth);
91
+ const request = oauth.commands.find(
92
+ (command) => command.name() === "request",
93
+ );
94
+ if (request) {
95
+ request.option("--json");
96
+ }
97
+ await program.parseAsync(["node", "test", "oauth", "request", ...args]);
98
+ } catch {
99
+ // Commander may throw under exitOverride for parse errors.
100
+ } finally {
101
+ process.stdout.write = originalWrite;
102
+ }
103
+
104
+ return {
105
+ stdout: Buffer.concat(chunks),
106
+ exitCode: Number(process.exitCode ?? 0),
107
+ };
108
+ }
109
+
110
+ describe("assistant oauth request", () => {
111
+ test("runs handleRequest in-process and does not call IPC", async () => {
112
+ const { stdout } = await runRequestCommand([
113
+ "--provider",
114
+ "google",
115
+ "-s",
116
+ "https://gmail.googleapis.com/gmail/v1/users/me/messages",
117
+ ]);
118
+
119
+ expect(ipcCalls).toEqual([]);
120
+ expect(handleRequestCalls).toEqual([
121
+ {
122
+ body: {
123
+ provider: "google",
124
+ url: "https://gmail.googleapis.com/gmail/v1/users/me/messages",
125
+ },
126
+ },
127
+ ]);
128
+ expect(stdout.toString("utf8")).toContain("hello");
129
+ });
130
+ });
131
+
132
+ describe("oauth request body encoding", () => {
133
+ const MULTIPART_BODY = [
134
+ "--boundary",
135
+ "Content-Type: application/json; charset=UTF-8",
136
+ "",
137
+ '{"name":"Sheet"}',
138
+ "--boundary--",
139
+ "",
140
+ ].join("\r\n");
141
+
142
+ const MULTIPART_HEADERS = {
143
+ "Content-Type": "multipart/related; boundary=boundary",
144
+ };
145
+
146
+ test("keeps a multipart body as the exact string it was given", async () => {
147
+ expect(readBodyData(MULTIPART_BODY, MULTIPART_HEADERS)).toBe(
148
+ MULTIPART_BODY,
149
+ );
150
+ });
151
+
152
+ test("parses a JSON body into an object", async () => {
153
+ expect(
154
+ readBodyData('{"name":"Sheet"}', {
155
+ "Content-Type": "application/json",
156
+ }),
157
+ ).toEqual({ name: "Sheet" });
158
+ });
159
+
160
+ test("parses a JSON body when no Content-Type is given", async () => {
161
+ expect(readBodyData('{"name":"Sheet"}', {})).toEqual({
162
+ name: "Sheet",
163
+ });
164
+ });
165
+
166
+ test("keeps a JSON-looking body raw under a non-JSON Content-Type", async () => {
167
+ expect(
168
+ readBodyData('{"name":"Sheet"}', { "content-type": "text/plain" }),
169
+ ).toBe('{"name":"Sheet"}');
170
+ });
171
+
172
+ test("keeps unparseable text raw when no Content-Type is given", async () => {
173
+ expect(readBodyData("not json at all", {})).toBe("not json at all");
174
+ });
175
+
176
+ test("reads a @file body under the caller's Content-Type", async () => {
177
+ const filePath = join(tempDir, "upload.txt");
178
+ writeFileSync(filePath, MULTIPART_BODY, "utf-8");
179
+
180
+ expect(readBodyData(`@${filePath}`, MULTIPART_HEADERS)).toBe(
181
+ MULTIPART_BODY,
182
+ );
183
+ });
184
+
185
+ test("forwards a multipart body to the route handler as a string", async () => {
186
+ const { exitCode } = await runRequestCommand([
187
+ "--provider",
188
+ "google",
189
+ "-s",
190
+ "-X",
191
+ "POST",
192
+ "-H",
193
+ "Content-Type: multipart/related; boundary=boundary",
194
+ "-d",
195
+ MULTIPART_BODY,
196
+ "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart",
197
+ ]);
198
+
199
+ expect(exitCode).toBe(0);
200
+ expect(handleRequestCalls).toEqual([
201
+ {
202
+ body: {
203
+ provider: "google",
204
+ url: "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart",
205
+ method: "POST",
206
+ headers: {
207
+ "Content-Type": "multipart/related; boundary=boundary",
208
+ },
209
+ parsed_data: MULTIPART_BODY,
210
+ },
211
+ },
212
+ ]);
213
+ });
214
+ });
215
+
216
+ describe("oauth request body output", () => {
217
+ beforeEach(() => {
218
+ handleRequestResult = {
219
+ ok: true,
220
+ status: 200,
221
+ headers: { "content-type": "application/octet-stream" },
222
+ body: PNG_MAGIC.toString("base64"),
223
+ bodyEncoding: "base64",
224
+ };
225
+ });
226
+
227
+ test("decodes a base64 envelope to raw bytes when writing a file", async () => {
228
+ const outputPath = join(tempDir, "drive.bin");
229
+ const { exitCode } = await runRequestCommand([
230
+ "--provider",
231
+ "google",
232
+ "-s",
233
+ "-o",
234
+ outputPath,
235
+ "https://www.googleapis.com/drive/v3/files/file-123?alt=media",
236
+ ]);
237
+
238
+ expect(exitCode).toBe(0);
239
+ expect(readFileSync(outputPath).equals(PNG_MAGIC)).toBe(true);
240
+ });
241
+
242
+ test("writes raw binary bytes to stdout without an extra newline", async () => {
243
+ const { stdout, exitCode } = await runRequestCommand([
244
+ "--provider",
245
+ "google",
246
+ "-s",
247
+ "https://www.googleapis.com/drive/v3/files/file-123?alt=media",
248
+ ]);
249
+
250
+ expect(exitCode).toBe(0);
251
+ expect(stdout.equals(PNG_MAGIC)).toBe(true);
252
+ });
253
+
254
+ test("keeps the encoded envelope in --json mode", async () => {
255
+ const { stdout, exitCode } = await runRequestCommand([
256
+ "--json",
257
+ "--provider",
258
+ "google",
259
+ "-s",
260
+ "https://www.googleapis.com/drive/v3/files/file-123?alt=media",
261
+ ]);
262
+
263
+ expect(exitCode).toBe(0);
264
+ const parsed = JSON.parse(stdout.toString("utf8")) as {
265
+ body: string;
266
+ bodyEncoding?: string;
267
+ };
268
+ expect(parsed.body).toBe(PNG_MAGIC.toString("base64"));
269
+ expect(parsed.bodyEncoding).toBe("base64");
270
+ });
271
+
272
+ test("writes text bodies as UTF-8 and appends a newline on stdout", async () => {
273
+ handleRequestResult = {
274
+ ok: true,
275
+ status: 200,
276
+ headers: { "content-type": "text/plain" },
277
+ body: "hello café",
278
+ };
279
+
280
+ const { stdout, exitCode } = await runRequestCommand([
281
+ "--provider",
282
+ "google",
283
+ "-s",
284
+ "https://www.googleapis.com/drive/v3/files/file-123",
285
+ ]);
286
+
287
+ expect(exitCode).toBe(0);
288
+ expect(stdout.toString("utf8")).toBe("hello café\n");
289
+ });
290
+ });