@vellumai/assistant 0.11.10 → 0.11.11-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 (199) hide show
  1. package/AGENTS.md +1 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  12. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  13. package/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  14. package/openapi.yaml +152 -55
  15. package/package.json +1 -1
  16. package/src/__tests__/anthropic-provider.test.ts +60 -0
  17. package/src/__tests__/config-get-vision-flag.test.ts +36 -0
  18. package/src/__tests__/conversation-error.test.ts +38 -0
  19. package/src/__tests__/copy-composer-tc-templates.test.ts +2 -2
  20. package/src/__tests__/guardian-action-service-scope.test.ts +53 -0
  21. package/src/__tests__/headless-browser-mode.test.ts +63 -13
  22. package/src/__tests__/heartbeat-disk-pressure.test.ts +2 -0
  23. package/src/__tests__/heartbeat-service.test.ts +118 -27
  24. package/src/__tests__/host-app-control-proxy.test.ts +2 -2
  25. package/src/__tests__/host-app-control-routes.test.ts +41 -0
  26. package/src/__tests__/host-bash-routes.test.ts +30 -1
  27. package/src/__tests__/host-browser-routes.test.ts +34 -0
  28. package/src/__tests__/host-cu-routes-targeted.test.ts +29 -0
  29. package/src/__tests__/host-file-routes-targeted.test.ts +29 -0
  30. package/src/__tests__/host-proxy-base.test.ts +1 -0
  31. package/src/__tests__/host-transfer-routes-targeted.test.ts +86 -0
  32. package/src/__tests__/llm-schema.test.ts +32 -0
  33. package/src/__tests__/mcp-config-secret-boundary.test.ts +2 -0
  34. package/src/__tests__/memory-config-file-parity.test.ts +18 -0
  35. package/src/__tests__/memory-retrieval-hook.test.ts +15 -0
  36. package/src/__tests__/messaging-send-tool.test.ts +159 -13
  37. package/src/__tests__/messaging-shared.test.ts +19 -0
  38. package/src/__tests__/notification-events-source-context-probe.test.ts +211 -0
  39. package/src/__tests__/openai-responses-prompt-cache.test.ts +40 -6
  40. package/src/__tests__/outlook-messaging-provider.test.ts +9 -0
  41. package/src/__tests__/provider-catalog-visibility.test.ts +2 -2
  42. package/src/__tests__/schedule-routes.test.ts +30 -1
  43. package/src/__tests__/scheduler-result-notification.test.ts +209 -0
  44. package/src/__tests__/skills-install-extract.test.ts +26 -0
  45. package/src/__tests__/skills-install-staging.test.ts +11 -2
  46. package/src/api/constants/personality-sliders.test.ts +21 -0
  47. package/src/api/constants/personality-sliders.ts +64 -0
  48. package/src/api/index.ts +8 -0
  49. package/src/archive/ustar-size.test.ts +32 -0
  50. package/src/archive/ustar-size.ts +23 -0
  51. package/src/background-wake/next-wake.test.ts +13 -0
  52. package/src/background-wake/next-wake.ts +4 -2
  53. package/src/calls/voice-session-bridge.ts +26 -0
  54. package/src/cli/__tests__/catalog-search-help.test.ts +1 -0
  55. package/src/cli/commands/__tests__/plugins-search.test.ts +81 -0
  56. package/src/cli/commands/config.help.ts +3 -1
  57. package/src/cli/commands/memory/worker.ts +1 -1
  58. package/src/cli/commands/platform/__tests__/credits-format.test.ts +94 -5
  59. package/src/cli/commands/platform/__tests__/credits.test.ts +10 -0
  60. package/src/cli/commands/platform/credits-format.ts +57 -0
  61. package/src/cli/commands/platform/index.help.ts +19 -3
  62. package/src/cli/commands/plugins.help.ts +4 -1
  63. package/src/cli/commands/plugins.ts +2 -1
  64. package/src/cli/lib/bundled-marketplace.json +1 -1
  65. package/src/cli/lib/install-from-platform.ts +8 -10
  66. package/src/config/bundled-skills/messaging/SKILL.md +2 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +4 -4
  68. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +85 -1
  69. package/src/config/bundled-skills/messaging/tools/shared.ts +6 -0
  70. package/src/config/bundled-skills/schedule/SKILL.md +9 -1
  71. package/src/config/bundled-skills/screen-annotation/SKILL.md +87 -20
  72. package/src/config/bundled-skills/screen-annotation/TOOLS.json +21 -5
  73. package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +67 -0
  74. package/src/config/feature-flag-registry.json +17 -1
  75. package/src/config/input-modalities.test.ts +71 -0
  76. package/src/config/input-modalities.ts +95 -0
  77. package/src/config/schemas/heartbeat.ts +3 -3
  78. package/src/config/schemas/llm.ts +13 -3
  79. package/src/config/schemas/memory-storage.test.ts +39 -0
  80. package/src/config/schemas/memory-storage.ts +21 -0
  81. package/src/context/token-estimator.ts +2 -2
  82. package/src/daemon/__tests__/embedding-reconcile.test.ts +77 -0
  83. package/src/daemon/__tests__/lifecycle-embedding-reconcile.test.ts +3 -0
  84. package/src/daemon/conversation-error.ts +20 -7
  85. package/src/daemon/embedding-reconcile.ts +64 -1
  86. package/src/daemon/handlers/config-embeddings.ts +31 -0
  87. package/src/daemon/host-app-control-proxy.ts +2 -0
  88. package/src/daemon/host-bash-proxy.ts +1 -0
  89. package/src/daemon/host-cu-proxy.ts +6 -6
  90. package/src/daemon/host-file-proxy.ts +6 -6
  91. package/src/daemon/host-proxy-base.ts +10 -9
  92. package/src/daemon/host-transfer-proxy.ts +21 -24
  93. package/src/daemon/mcp-reload-service.ts +6 -0
  94. package/src/daemon/providers-setup.ts +5 -26
  95. package/src/heartbeat/heartbeat-service.ts +72 -13
  96. package/src/live-voice/__tests__/live-voice-photo.test.ts +72 -0
  97. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +81 -0
  98. package/src/live-voice/live-voice-photo.ts +68 -4
  99. package/src/live-voice/live-voice-session.ts +36 -2
  100. package/src/live-voice/protocol.ts +98 -1
  101. package/src/mcp/__tests__/reload-signal-emission.test.ts +54 -0
  102. package/src/mcp/__tests__/reload-signal.test.ts +57 -0
  103. package/src/mcp/__tests__/startup.test.ts +150 -0
  104. package/src/mcp/reload-signal.ts +50 -0
  105. package/src/mcp/startup.ts +101 -0
  106. package/src/messaging/providers/outlook/adapter.ts +2 -13
  107. package/src/messaging/providers/outlook/client.ts +13 -1
  108. package/src/messaging/providers/outlook/types.ts +3 -0
  109. package/src/notifications/__tests__/decision-engine.test.ts +79 -6
  110. package/src/notifications/__tests__/home-feed-side-effect.test.ts +27 -0
  111. package/src/notifications/__tests__/schedule-result-producer.test.ts +397 -0
  112. package/src/notifications/copy-composer.ts +33 -11
  113. package/src/notifications/decision-engine.ts +48 -11
  114. package/src/notifications/events-store.ts +68 -2
  115. package/src/notifications/home-feed-side-effect.ts +15 -3
  116. package/src/notifications/schedule-result-producer.ts +275 -0
  117. package/src/notifications/signal.ts +5 -0
  118. package/src/oauth/seed-providers.ts +3 -2
  119. package/src/persistence/conversation-crud.ts +38 -0
  120. package/src/persistence/embeddings/embedding-backend.test.ts +162 -0
  121. package/src/persistence/embeddings/embedding-backend.ts +148 -11
  122. package/src/persistence/embeddings/embedding-openai.test.ts +113 -0
  123. package/src/persistence/embeddings/embedding-openai.ts +55 -4
  124. package/src/persistence/embeddings/embedding-types.test.ts +24 -0
  125. package/src/persistence/embeddings/embedding-types.ts +25 -1
  126. package/src/persistence/job-utils.ts +6 -2
  127. package/src/plugin-api/index.ts +2 -1
  128. package/src/plugin-api/vision-support.test.ts +81 -0
  129. package/src/plugin-api/vision-support.ts +57 -10
  130. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +8 -0
  131. package/src/plugins/defaults/memory/jobs/__tests__/embed-concept-page.test.ts +1 -0
  132. package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +11 -3
  133. package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +20 -0
  134. package/src/plugins/defaults/memory/src/memory-worker-routes.ts +3 -1
  135. package/src/plugins/defaults/memory/substrate/__tests__/reembed-job.test.ts +1 -0
  136. package/src/plugins/defaults/memory/v2/__tests__/backfill-jobs.test.ts +1 -0
  137. package/src/plugins/defaults/memory/v3/__tests__/section-dense-store.test.ts +82 -0
  138. package/src/plugins/defaults/memory/v3/section-dense-store.ts +9 -27
  139. package/src/providers/__tests__/provider-secret-catalog.test.ts +4 -0
  140. package/src/providers/__tests__/xml-tool-call-salvage.test.ts +172 -0
  141. package/src/providers/anthropic/client.ts +9 -4
  142. package/src/providers/inline-audio-support.test.ts +34 -0
  143. package/src/providers/inline-audio-support.ts +27 -0
  144. package/src/providers/model-catalog.ts +22 -2
  145. package/src/providers/openai/__tests__/api-error-normalization.test.ts +50 -0
  146. package/src/providers/openai/__tests__/chat-completions-extra-body.test.ts +51 -0
  147. package/src/providers/openai/__tests__/chat-completions-provider-xml-salvage.test.ts +291 -0
  148. package/src/providers/openai/api-error-normalization.ts +18 -3
  149. package/src/providers/openai/chat-completions-provider.ts +113 -13
  150. package/src/providers/openai/responses-provider.ts +10 -8
  151. package/src/providers/provider-secret-catalog.ts +11 -1
  152. package/src/providers/vellum/client.ts +14 -1
  153. package/src/providers/vellum/personality-directions.test.ts +163 -0
  154. package/src/providers/vellum/personality-directions.ts +120 -0
  155. package/src/providers/xml-tool-call-salvage.ts +212 -0
  156. package/src/runtime/__tests__/background-job-runner.test.ts +42 -4
  157. package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -6
  158. package/src/runtime/auth/__tests__/route-policy.test.ts +65 -0
  159. package/src/runtime/auth/same-actor.ts +29 -5
  160. package/src/runtime/background-job-runner.ts +38 -29
  161. package/src/runtime/guardian-action-service.ts +20 -1
  162. package/src/runtime/migrations/vbundle-validator.ts +3 -3
  163. package/src/runtime/pending-interactions.ts +4 -5
  164. package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +24 -0
  165. package/src/runtime/routes/__tests__/platform-credits-routes.test.ts +99 -5
  166. package/src/runtime/routes/conversation-query-routes.ts +53 -11
  167. package/src/runtime/routes/document-comments-routes.ts +8 -2
  168. package/src/runtime/routes/guardian-action-routes.ts +7 -0
  169. package/src/runtime/routes/heartbeat-routes.ts +41 -53
  170. package/src/runtime/routes/host-app-control-routes.ts +10 -36
  171. package/src/runtime/routes/host-bash-routes.ts +11 -45
  172. package/src/runtime/routes/host-browser-routes.ts +24 -0
  173. package/src/runtime/routes/host-cu-routes.ts +11 -45
  174. package/src/runtime/routes/host-file-routes.ts +11 -44
  175. package/src/runtime/routes/host-proxy-result-binding.ts +57 -0
  176. package/src/runtime/routes/host-transfer-routes.ts +33 -25
  177. package/src/runtime/routes/index.ts +0 -2
  178. package/src/runtime/routes/integrations/a2a.ts +12 -3
  179. package/src/runtime/routes/integrations/vercel.ts +13 -3
  180. package/src/runtime/routes/platform-routes.ts +121 -24
  181. package/src/runtime/routes/schedule-routes.ts +15 -1
  182. package/src/schedule/__tests__/schedule-timezone.test.ts +21 -3
  183. package/src/schedule/__tests__/worker-mcp-tools.test.ts +88 -0
  184. package/src/schedule/schedule-timezone.ts +25 -0
  185. package/src/schedule/scheduler.ts +19 -0
  186. package/src/schedule/worker.ts +119 -2
  187. package/src/skills/catalog-install.ts +40 -5
  188. package/src/tools/browser/__tests__/browser-status.test.ts +3 -0
  189. package/src/tools/browser/browser-execution.ts +117 -53
  190. package/src/tools/browser/browser-status-constants.ts +6 -6
  191. package/src/tools/capability-offer.test.ts +87 -0
  192. package/src/tools/capability-offer.ts +93 -0
  193. package/src/tools/terminal/__tests__/safe-env.test.ts +17 -0
  194. package/src/tools/terminal/safe-env.ts +3 -2
  195. package/src/util/provider-error-patterns.ts +34 -0
  196. package/src/watcher/__tests__/engine.test.ts +5 -2
  197. package/src/watcher/engine.ts +7 -4
  198. package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +0 -487
  199. package/src/runtime/routes/sight-frame-routes.ts +0 -136
@@ -0,0 +1,291 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ OpenAIChatCompletionsProvider,
5
+ type OpenAIChatCompletionsProviderOptions,
6
+ } from "../chat-completions-provider.js";
7
+
8
+ type MockChunkDelta = {
9
+ content?: string | null;
10
+ tool_calls?: Array<{
11
+ index: number;
12
+ id?: string;
13
+ function?: { name?: string; arguments?: string };
14
+ }>;
15
+ };
16
+
17
+ type MockChunk = {
18
+ choices: Array<{ delta: MockChunkDelta; finish_reason?: string | null }>;
19
+ usage?: { prompt_tokens: number; completion_tokens: number };
20
+ };
21
+
22
+ const BASH_TOOL = {
23
+ name: "bash",
24
+ description: "Run a shell command",
25
+ input_schema: {
26
+ type: "object",
27
+ properties: {
28
+ command: { type: "string" },
29
+ activity: { type: "string" },
30
+ timeout_seconds: { type: "number" },
31
+ },
32
+ },
33
+ };
34
+
35
+ const INVOKE_XML = `<invoke name="bash">
36
+ <parameter name="command">cd /workspace/example && git status</parameter>
37
+ <parameter name="activity">Checking live repo state</parameter>
38
+ <parameter name="timeout_seconds">60</parameter>
39
+ </invoke>`;
40
+
41
+ function makeStream(chunks: MockChunk[]): AsyncIterable<MockChunk> {
42
+ return {
43
+ async *[Symbol.asyncIterator]() {
44
+ for (const c of chunks) {
45
+ yield c;
46
+ }
47
+ },
48
+ };
49
+ }
50
+
51
+ function stubProvider(
52
+ chunks: MockChunk[],
53
+ options?: OpenAIChatCompletionsProviderOptions,
54
+ model = "test-model",
55
+ ): {
56
+ provider: OpenAIChatCompletionsProvider;
57
+ events: Array<{ type: string; text?: string }>;
58
+ } {
59
+ const provider = new OpenAIChatCompletionsProvider("test-key", model, options);
60
+ (provider as unknown as { client: unknown }).client = {
61
+ chat: {
62
+ completions: {
63
+ create: async () => makeStream(chunks),
64
+ },
65
+ },
66
+ };
67
+ return { provider, events: [] };
68
+ }
69
+
70
+ function contentChunks(parts: string[], finishReason = "stop"): MockChunk[] {
71
+ return [
72
+ ...parts.map((content) => ({
73
+ choices: [{ delta: { content } }],
74
+ })),
75
+ {
76
+ choices: [{ delta: {}, finish_reason: finishReason }],
77
+ usage: { prompt_tokens: 1, completion_tokens: 2 },
78
+ },
79
+ ];
80
+ }
81
+
82
+ describe("OpenAIChatCompletionsProvider XML tool-call salvage", () => {
83
+ test("converts streamed invoke XML into a tool_use block and holds it back from text_delta", async () => {
84
+ const { provider, events } = stubProvider(
85
+ contentChunks([
86
+ "Let me actually run the check.\n\n",
87
+ "<inv",
88
+ `oke name="bash">\n`,
89
+ `<parameter name="command">cd /workspace/example && git status</parameter>\n`,
90
+ `<parameter name="activity">Checking live repo state</parameter>\n`,
91
+ `<parameter name="timeout_seconds">60</parameter>\n`,
92
+ "</invoke>",
93
+ ]),
94
+ { salvageXmlToolCalls: true },
95
+ );
96
+
97
+ const response = await provider.sendMessage(
98
+ [{ role: "user", content: [{ type: "text", text: "check the repo" }] }],
99
+ {
100
+ tools: [BASH_TOOL],
101
+ onEvent: (e) => {
102
+ events.push(e as { type: string; text?: string });
103
+ },
104
+ },
105
+ );
106
+
107
+ const textDeltas = events
108
+ .filter((e) => e.type === "text_delta")
109
+ .map((e) => e.text ?? "")
110
+ .join("");
111
+ expect(textDeltas).toBe("Let me actually run the check.\n\n");
112
+ expect(textDeltas).not.toContain("<invoke");
113
+ expect(textDeltas).not.toContain("<inv");
114
+
115
+ const textBlock = response.content.find((b) => b.type === "text") as
116
+ | { type: "text"; text: string }
117
+ | undefined;
118
+ expect(textBlock?.text).toBe("Let me actually run the check.");
119
+
120
+ const toolUse = response.content.filter((b) => b.type === "tool_use");
121
+ expect(toolUse).toEqual([
122
+ {
123
+ type: "tool_use",
124
+ id: expect.stringMatching(/^call_/),
125
+ name: "bash",
126
+ input: {
127
+ command: "cd /workspace/example && git status",
128
+ activity: "Checking live repo state",
129
+ timeout_seconds: 60,
130
+ },
131
+ },
132
+ ]);
133
+ expect(response.stopReason).toBe("tool_calls");
134
+ });
135
+
136
+ test("salvages invoke XML when parseThinkTags is enabled", async () => {
137
+ const { provider } = stubProvider(
138
+ contentChunks([`Okay.\n${INVOKE_XML}`]),
139
+ { parseThinkTags: true, salvageXmlToolCalls: true },
140
+ );
141
+
142
+ const response = await provider.sendMessage(
143
+ [{ role: "user", content: [{ type: "text", text: "hi" }] }],
144
+ { tools: [BASH_TOOL] },
145
+ );
146
+
147
+ expect(response.content.some((b) => b.type === "tool_use")).toBe(true);
148
+ const textBlock = response.content.find((b) => b.type === "text") as
149
+ | { type: "text"; text: string }
150
+ | undefined;
151
+ expect(textBlock?.text).toBe("Okay.");
152
+ });
153
+
154
+ test("leaves XML as text when native tool_calls are already present", async () => {
155
+ const { provider } = stubProvider(
156
+ [
157
+ { choices: [{ delta: { content: `${INVOKE_XML}\n` } }] },
158
+ {
159
+ choices: [
160
+ {
161
+ delta: {
162
+ tool_calls: [
163
+ {
164
+ index: 0,
165
+ id: "call_native",
166
+ function: {
167
+ name: "bash",
168
+ arguments: '{"command":"pwd"}',
169
+ },
170
+ },
171
+ ],
172
+ },
173
+ },
174
+ ],
175
+ },
176
+ {
177
+ choices: [{ delta: {}, finish_reason: "tool_calls" }],
178
+ usage: { prompt_tokens: 1, completion_tokens: 2 },
179
+ },
180
+ ],
181
+ { salvageXmlToolCalls: true },
182
+ );
183
+
184
+ const response = await provider.sendMessage(
185
+ [{ role: "user", content: [{ type: "text", text: "hi" }] }],
186
+ { tools: [BASH_TOOL] },
187
+ );
188
+
189
+ const toolUse = response.content.filter((b) => b.type === "tool_use");
190
+ expect(toolUse).toHaveLength(1);
191
+ expect(toolUse[0]).toMatchObject({
192
+ id: "call_native",
193
+ name: "bash",
194
+ input: { command: "pwd" },
195
+ });
196
+ const textBlock = response.content.find((b) => b.type === "text") as
197
+ | { type: "text"; text: string }
198
+ | undefined;
199
+ expect(textBlock?.text).toContain("<invoke");
200
+ });
201
+
202
+ test("leaves XML as text when no tools were offered", async () => {
203
+ const { provider, events } = stubProvider(contentChunks([INVOKE_XML]));
204
+
205
+ const response = await provider.sendMessage(
206
+ [{ role: "user", content: [{ type: "text", text: "hi" }] }],
207
+ {
208
+ onEvent: (e) => {
209
+ events.push(e as { type: string; text?: string });
210
+ },
211
+ },
212
+ );
213
+
214
+ expect(response.content.some((b) => b.type === "tool_use")).toBe(false);
215
+ expect(
216
+ events
217
+ .filter((e) => e.type === "text_delta")
218
+ .map((e) => e.text ?? "")
219
+ .join(""),
220
+ ).toContain("<invoke");
221
+ });
222
+
223
+ test("leaves XML as text when tool_choice is none", async () => {
224
+ const { provider } = stubProvider(contentChunks([INVOKE_XML]), {
225
+ salvageXmlToolCalls: true,
226
+ });
227
+
228
+ const response = await provider.sendMessage(
229
+ [{ role: "user", content: [{ type: "text", text: "hi" }] }],
230
+ {
231
+ tools: [BASH_TOOL],
232
+ config: { tool_choice: { type: "none" } },
233
+ },
234
+ );
235
+
236
+ expect(response.content.some((b) => b.type === "tool_use")).toBe(false);
237
+ const textBlock = response.content.find((b) => b.type === "text") as
238
+ | { type: "text"; text: string }
239
+ | undefined;
240
+ expect(textBlock?.text).toContain("<invoke");
241
+ });
242
+
243
+ test("leaves XML as text on non-DeepSeek models unless salvage is opted in", async () => {
244
+ const { provider } = stubProvider(contentChunks([INVOKE_XML]));
245
+
246
+ const response = await provider.sendMessage(
247
+ [{ role: "user", content: [{ type: "text", text: "hi" }] }],
248
+ { tools: [BASH_TOOL] },
249
+ );
250
+
251
+ expect(response.content.some((b) => b.type === "tool_use")).toBe(false);
252
+ const textBlock = response.content.find((b) => b.type === "text") as
253
+ | { type: "text"; text: string }
254
+ | undefined;
255
+ expect(textBlock?.text).toContain("<invoke");
256
+ });
257
+
258
+ test("salvages by default when the model id is DeepSeek", async () => {
259
+ const { provider } = stubProvider(
260
+ contentChunks([INVOKE_XML]),
261
+ undefined,
262
+ "accounts/fireworks/models/deepseek-v4-flash-0731",
263
+ );
264
+
265
+ const response = await provider.sendMessage(
266
+ [{ role: "user", content: [{ type: "text", text: "hi" }] }],
267
+ { tools: [BASH_TOOL] },
268
+ );
269
+
270
+ expect(response.content.some((b) => b.type === "tool_use")).toBe(true);
271
+ });
272
+
273
+ test("does not salvage DeepSeek when salvageXmlToolCalls is false", async () => {
274
+ const { provider } = stubProvider(
275
+ contentChunks([INVOKE_XML]),
276
+ { salvageXmlToolCalls: false },
277
+ "accounts/fireworks/models/deepseek-v4-flash-0731",
278
+ );
279
+
280
+ const response = await provider.sendMessage(
281
+ [{ role: "user", content: [{ type: "text", text: "hi" }] }],
282
+ { tools: [BASH_TOOL] },
283
+ );
284
+
285
+ expect(response.content.some((b) => b.type === "tool_use")).toBe(false);
286
+ const textBlock = response.content.find((b) => b.type === "text") as
287
+ | { type: "text"; text: string }
288
+ | undefined;
289
+ expect(textBlock?.text).toContain("<invoke");
290
+ });
291
+ });
@@ -5,6 +5,8 @@ import {
5
5
  DAILY_LIMIT_PATTERNS,
6
6
  INSUFFICIENT_CREDITS_PATTERNS,
7
7
  isChatTemplateFailureError,
8
+ MODEL_NOT_FOUND_PATTERNS,
9
+ UNSUPPORTED_MODEL_ID_PATTERNS,
8
10
  VISION_NOT_SUPPORTED_PATTERNS,
9
11
  } from "../../util/provider-error-patterns.js";
10
12
 
@@ -52,8 +54,8 @@ export function normalizedErrorText(n: NormalizedOpenAIAPIError): string {
52
54
 
53
55
  /**
54
56
  * Map an OpenAI-compatible error to a semantic {@link ProviderErrorReason}.
55
- * Order matters — the model-restriction check precedes the generic 401/403
56
- * credential branch, and billing precedes credentials.
57
+ * Order matters: model restriction and model-not-found precede the generic
58
+ * 401/403 credential branch, and billing precedes credentials.
57
59
  */
58
60
  export function deriveReason(
59
61
  n: NormalizedOpenAIAPIError,
@@ -72,12 +74,25 @@ export function deriveReason(
72
74
  }
73
75
 
74
76
  if (
75
- /model .*(?:not found|does not exist)/i.test(haystack) ||
77
+ MODEL_NOT_FOUND_PATTERNS.some((re) => re.test(haystack)) ||
76
78
  /model_not_found/i.test(`${n.apiErrorCode ?? ""} ${n.apiErrorType ?? ""}`)
77
79
  ) {
78
80
  return "model_not_found";
79
81
  }
80
82
 
83
+ // OpenCode's unsupported-model payload uses type=ModelError on a 401.
84
+ // Gate on 4xx so a 5xx that happens to carry that type stays server_error
85
+ // (retryable) instead of model_not_found (not retryable, fallback-eligible).
86
+ if (
87
+ status !== undefined &&
88
+ status >= 400 &&
89
+ status < 500 &&
90
+ (UNSUPPORTED_MODEL_ID_PATTERNS.some((re) => re.test(haystack)) ||
91
+ /ModelError/i.test(`${n.apiErrorCode ?? ""} ${n.apiErrorType ?? ""}`))
92
+ ) {
93
+ return "model_not_found";
94
+ }
95
+
81
96
  if (VISION_NOT_SUPPORTED_PATTERNS.some((re) => re.test(haystack))) {
82
97
  return "vision_unsupported";
83
98
  }
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from "node:crypto";
2
+
1
3
  import OpenAI from "openai";
2
4
 
3
5
  import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../prompts/cache-boundary.js";
@@ -9,12 +11,12 @@ import { extractRetryAfterMs } from "../../util/retry.js";
9
11
  import { partialTagSuffix as sharedPartialTagSuffix } from "../../util/think-tag-stream.js";
10
12
  import { clampProviderString } from "../content-block-size.js";
11
13
  import { fileBlockToProviderText } from "../file-block-text.js";
14
+ import { requestSupportsInlineAudio } from "../inline-audio-support.js";
12
15
  import {
13
16
  base64Source,
14
17
  mediaSourceByteLength,
15
18
  resolveMediaReferences,
16
19
  } from "../media-resolve.js";
17
- import { modelSupportsAudioInput } from "../model-catalog.js";
18
20
  import { PLACEHOLDER_EMPTY_TURN } from "../placeholder-sentinels.js";
19
21
  import { recordProviderRequestDiagnostics } from "../request-diagnostics.js";
20
22
  import { createStreamTimeout } from "../stream-timeout.js";
@@ -34,6 +36,11 @@ import {
34
36
  isUnparseableToolArgs,
35
37
  wrapUnparseableToolArgs,
36
38
  } from "../unparseable-tool-args.js";
39
+ import {
40
+ salvageXmlToolCalls,
41
+ shouldSalvageXmlToolCalls,
42
+ splitXmlToolCallHoldback,
43
+ } from "../xml-tool-call-salvage.js";
37
44
  import {
38
45
  captureRawErrorBodyFetch,
39
46
  formatNormalizedOpenAIAPIError,
@@ -177,6 +184,12 @@ export interface OpenAIChatCompletionsProviderOptions {
177
184
  * with minimax-m3 on Fireworks). Off by default; scalars/arrays unaffected.
178
185
  * See {@link coerceObjectParamsToJsonString}. */
179
186
  coerceObjectArgsToJsonString?: boolean;
187
+ /**
188
+ * Convert complete `<invoke>` XML in assistant text into `tool_use` blocks.
189
+ * Defaults to on for DeepSeek model ids and off otherwise. Set explicitly to
190
+ * override the model-id default.
191
+ */
192
+ salvageXmlToolCalls?: boolean;
180
193
  /** Drop `tool_choice` when thinking/reasoning is on the wire. Strict
181
194
  * OpenAI-compatible reasoning upstreams (DeepSeek thinking mode) reject any
182
195
  * explicit `tool_choice` with `Thinking mode does not support this
@@ -692,8 +705,11 @@ export class OpenAIChatCompletionsProvider implements Provider {
692
705
  private requestHeaders: Record<string, string>;
693
706
  private parseThinkTags: boolean;
694
707
  private assistantReasoningField:
695
- "reasoning" | "reasoning_content" | undefined;
708
+ | "reasoning"
709
+ | "reasoning_content"
710
+ | undefined;
696
711
  private coerceObjectArgsToJsonString: boolean;
712
+ private salvageXmlToolCalls: boolean;
697
713
  private omitToolChoiceWhenReasoning: boolean;
698
714
 
699
715
  constructor(
@@ -722,6 +738,8 @@ export class OpenAIChatCompletionsProvider implements Provider {
722
738
  this.assistantReasoningField = options.assistantReasoningField;
723
739
  this.coerceObjectArgsToJsonString =
724
740
  options.coerceObjectArgsToJsonString ?? false;
741
+ this.salvageXmlToolCalls =
742
+ options.salvageXmlToolCalls ?? shouldSalvageXmlToolCalls(model);
725
743
  this.omitToolChoiceWhenReasoning =
726
744
  options.omitToolChoiceWhenReasoning ?? false;
727
745
  }
@@ -740,12 +758,15 @@ export class OpenAIChatCompletionsProvider implements Provider {
740
758
  const modelOverride = configObj?.model as string | undefined;
741
759
  const effort = configObj?.effort as string | undefined;
742
760
  const logitBias = configObj?.logit_bias as
743
- Record<string, number> | undefined;
761
+ | Record<string, number>
762
+ | undefined;
744
763
  const topP = configObj?.top_p as number | undefined;
745
764
  const usageAttributionHeaders = configObj?.usageAttributionHeaders as
746
- Record<string, string> | undefined;
765
+ | Record<string, string>
766
+ | undefined;
747
767
  const perRequestHeaders = configObj?.requestHeaders as
748
- Record<string, string> | undefined;
768
+ | Record<string, string>
769
+ | undefined;
749
770
 
750
771
  // Per-tool keys whose object schemas were rewritten to JSON strings for the
751
772
  // wire, to be decoded back on the response. Empty unless
@@ -756,7 +777,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
756
777
  const openaiMessages = await this.toOpenAIMessages(
757
778
  messages,
758
779
  systemPrompt,
759
- modelSupportsAudioInput(modelOverride ?? this.model),
780
+ requestSupportsInlineAudio(modelOverride ?? this.model),
760
781
  );
761
782
 
762
783
  recordProviderRequestDiagnostics({
@@ -812,6 +833,9 @@ export class OpenAIChatCompletionsProvider implements Provider {
812
833
  ) as OpenAI.Chat.Completions.ChatCompletionCreateParams["reasoning_effort"];
813
834
  }
814
835
 
836
+ const offeredToolNames = new Set((tools ?? []).map((t) => t.name));
837
+ let xmlToolCallSalvageEnabled = false;
838
+
815
839
  if (tools && tools.length > 0) {
816
840
  params.tools = tools.map((t) => {
817
841
  let parameters = t.input_schema as OpenAI.FunctionParameters;
@@ -844,6 +868,8 @@ export class OpenAIChatCompletionsProvider implements Provider {
844
868
  // receive them; the generic openai-compatible adapter drops every
845
869
  // explicit value in thinking mode via `omitToolChoiceWhenReasoning`.
846
870
  const toolChoice = mapNeutralToolChoice(configObj?.tool_choice);
871
+ xmlToolCallSalvageEnabled =
872
+ this.salvageXmlToolCalls && toolChoice !== "none";
847
873
  if (toolChoice !== undefined) {
848
874
  const thinkingOn = isThinkingEnabledOnWire(params);
849
875
  const skipAutoDefault = thinkingOn && toolChoice === "auto";
@@ -862,6 +888,33 @@ export class OpenAIChatCompletionsProvider implements Provider {
862
888
  let reasoningText = "";
863
889
  let insideThinkBlock = false;
864
890
  let pendingContent = "";
891
+ let xmlHeld = "";
892
+
893
+ const emitVisibleText = (delta: string): void => {
894
+ if (!delta) {
895
+ return;
896
+ }
897
+ if (!xmlToolCallSalvageEnabled) {
898
+ contentText += delta;
899
+ onEvent?.({ type: "text_delta", text: delta });
900
+ return;
901
+ }
902
+ const split = splitXmlToolCallHoldback(xmlHeld + delta);
903
+ xmlHeld = split.held;
904
+ if (split.visible) {
905
+ contentText += split.visible;
906
+ onEvent?.({ type: "text_delta", text: split.visible });
907
+ }
908
+ };
909
+
910
+ const flushHeldXmlAsText = (): void => {
911
+ if (!xmlHeld) {
912
+ return;
913
+ }
914
+ contentText += xmlHeld;
915
+ onEvent?.({ type: "text_delta", text: xmlHeld });
916
+ xmlHeld = "";
917
+ };
865
918
 
866
919
  const flushPendingContent = (final: boolean): void => {
867
920
  while (pendingContent.length > 0) {
@@ -896,8 +949,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
896
949
  if (openIdx >= 0) {
897
950
  const text = pendingContent.substring(0, openIdx);
898
951
  if (text) {
899
- contentText += text;
900
- onEvent?.({ type: "text_delta", text });
952
+ emitVisibleText(text);
901
953
  }
902
954
  insideThinkBlock = true;
903
955
  pendingContent = pendingContent.substring(
@@ -909,9 +961,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
909
961
  : partialTagSuffix(pendingContent, "<think>");
910
962
  const safeLen = pendingContent.length - partial;
911
963
  if (safeLen > 0) {
912
- const t = pendingContent.substring(0, safeLen);
913
- contentText += t;
914
- onEvent?.({ type: "text_delta", text: t });
964
+ emitVisibleText(pendingContent.substring(0, safeLen));
915
965
  }
916
966
  pendingContent =
917
967
  partial > 0 ? pendingContent.substring(safeLen) : "";
@@ -940,6 +990,10 @@ export class OpenAIChatCompletionsProvider implements Provider {
940
990
  ...(usageAttributionHeaders ?? {}),
941
991
  ...(perRequestHeaders ?? {}),
942
992
  };
993
+ const extraBody = this.buildRequestExtraBody(options);
994
+ if (extraBody) {
995
+ Object.assign(params, extraBody);
996
+ }
943
997
  const createStream = () =>
944
998
  this.client.chat.completions.create(params, {
945
999
  signal: timeoutSignal,
@@ -1020,8 +1074,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
1020
1074
  pendingContent += choice.delta.content;
1021
1075
  flushPendingContent(false);
1022
1076
  } else {
1023
- contentText += choice.delta.content;
1024
- onEvent?.({ type: "text_delta", text: choice.delta.content });
1077
+ emitVisibleText(choice.delta.content);
1025
1078
  }
1026
1079
  }
1027
1080
 
@@ -1142,6 +1195,42 @@ export class OpenAIChatCompletionsProvider implements Provider {
1142
1195
  flushPendingContent(true);
1143
1196
  }
1144
1197
 
1198
+ // DeepSeek (and any caller that sets salvageXmlToolCalls) may emit
1199
+ // `<invoke>` XML as assistant text instead of native `tool_calls`.
1200
+ // Convert complete offered-tool invokes into tool_use blocks so the
1201
+ // agent loop can execute them. Native tool_calls win.
1202
+ if (xmlToolCallSalvageEnabled && toolCallMap.size === 0) {
1203
+ const salvaged = salvageXmlToolCalls(
1204
+ contentText + xmlHeld,
1205
+ offeredToolNames,
1206
+ );
1207
+ if (salvaged) {
1208
+ contentText = salvaged.text;
1209
+ xmlHeld = "";
1210
+ log.info(
1211
+ {
1212
+ salvagedToolCount: salvaged.calls.length,
1213
+ salvagedToolNames: salvaged.calls.map((call) => call.name),
1214
+ },
1215
+ "Converted XML-formatted assistant text into native tool calls",
1216
+ );
1217
+ for (const [index, call] of salvaged.calls.entries()) {
1218
+ toolCallMap.set(index, {
1219
+ id: `call_${randomUUID()}`,
1220
+ name: call.name,
1221
+ args: JSON.stringify(call.input),
1222
+ });
1223
+ }
1224
+ if (finishReason === "stop" || finishReason === "unknown") {
1225
+ finishReason = "tool_calls";
1226
+ }
1227
+ } else {
1228
+ flushHeldXmlAsText();
1229
+ }
1230
+ } else {
1231
+ flushHeldXmlAsText();
1232
+ }
1233
+
1145
1234
  // Build content blocks
1146
1235
  const finalReasoning = this.parseThinkTags
1147
1236
  ? reasoningText.trim()
@@ -1362,6 +1451,17 @@ export class OpenAIChatCompletionsProvider implements Provider {
1362
1451
  return this.extraCreateParams;
1363
1452
  }
1364
1453
 
1454
+ /**
1455
+ * Fields merged onto the `chat.completions.create` params object. The
1456
+ * OpenAI Node SDK sends unknown body fields as-is, so hosted-Qwen
1457
+ * `directions` reach the runtime proxy on the JSON body.
1458
+ */
1459
+ protected buildRequestExtraBody(
1460
+ _options?: SendMessageOptions,
1461
+ ): Record<string, unknown> | undefined {
1462
+ return undefined;
1463
+ }
1464
+
1365
1465
  /**
1366
1466
  * Per-request reasoning_effort ceiling. Defaults to the provider-wide
1367
1467
  * `maxReasoningEffort` from constructor options. Subclasses (e.g. Fireworks)
@@ -47,7 +47,7 @@ export interface OpenAIResponsesProviderOptions {
47
47
  streamTimeoutMs?: number;
48
48
  useNativeWebSearch?: boolean;
49
49
  /** When true, target the Codex subscription endpoint and strip fields it
50
- * rejects (`max_output_tokens`). */
50
+ * rejects (`max_output_tokens`, `prompt_cache_options`, breakpoints). */
51
51
  codexSubscription?: boolean;
52
52
  /** Static HTTP headers sent with every request (e.g. OpenRouter app
53
53
  * attribution). Merged under any per-request attribution headers. */
@@ -309,13 +309,13 @@ export class OpenAIResponsesProvider implements Provider {
309
309
 
310
310
  // A per-conversation prompt-cache key gives OpenAI's cache router a
311
311
  // stable affinity key so a conversation's requests land on the same
312
- // cache shard. Every model on the direct API receives it — both
312
+ // cache shard. Every Responses model receives it, including the Codex
313
+ // subscription endpoint: the key is a supported routing field there
314
+ // (the official Codex client defaults it from thread identity). Both
313
315
  // breakpoint-capable models (which additionally opt into explicit mode
314
- // below) and implicit-mode models, which carry no explicit breakpoints
315
- // yet still gain prefix-cache routing affinity from a stable key. The
316
- // Codex subscription endpoint rejects extra params, so it is skipped
317
- // there.
318
- if (!this.codexSubscription && promptCacheKey) {
316
+ // below) and implicit-mode models, which carry no explicit breakpoints,
317
+ // still gain prefix-cache routing affinity from a stable key.
318
+ if (promptCacheKey) {
319
319
  params.prompt_cache_key = promptCacheKey;
320
320
  }
321
321
 
@@ -329,7 +329,9 @@ export class OpenAIResponsesProvider implements Provider {
329
329
  // breakpoints neither uses the cache nor incurs cache-write charges,
330
330
  // which is exactly the opt-out `disableCache` wants (omitting the param
331
331
  // would re-enable implicit mode). The Codex subscription endpoint
332
- // rejects extra params, so these params are skipped entirely there.
332
+ // rejects `prompt_cache_options` and block-level breakpoints, so those
333
+ // stay off there. Codex runs in implicit prefix-cache mode and relies
334
+ // on `prompt_cache_key` above for routing affinity.
333
335
  if (
334
336
  !this.codexSubscription &&
335
337
  PROMPT_CACHE_BREAKPOINT_MODEL_IDS.has(effectiveModel)
@@ -2,7 +2,7 @@
2
2
  * Canonical source for API-key-addressable providers.
3
3
  *
4
4
  * This module composes the full set of providers that store API keys in
5
- * secure storage (the `api_key` secret type) from four sources:
5
+ * secure storage (the `api_key` secret type) from five sources:
6
6
  *
7
7
  * 1. **LLM providers** -- derived from `PROVIDER_CATALOG`
8
8
  * (`model-catalog.ts`). Adding a provider to the catalog automatically
@@ -15,6 +15,8 @@
15
15
  * 4. **TTS catalog providers** -- dynamically derived from the canonical
16
16
  * TTS provider catalog by selecting entries whose secret requirements
17
17
  * use the bare-name (non-credential) storage convention.
18
+ * 5. **Embedding-only providers** -- currently `custom`, the
19
+ * OpenAI-compatible memory embeddings endpoint.
18
20
  *
19
21
  * Consumers that need the set of valid API-key provider names should
20
22
  * import {@link API_KEY_PROVIDERS} from this module rather than
@@ -116,6 +118,13 @@ function catalogApiKeyNames(): string[] {
116
118
  );
117
119
  }
118
120
 
121
+ /**
122
+ * Embedding-only API-key names. `custom` is the OpenAI-compatible memory
123
+ * embeddings provider and is not an LLM catalog entry, so it is declared
124
+ * here rather than in `PROVIDER_CATALOG`.
125
+ */
126
+ const EMBEDDING_API_KEY_PROVIDERS: readonly string[] = ["custom"];
127
+
119
128
  // ---------------------------------------------------------------------------
120
129
  // Unified export
121
130
  // ---------------------------------------------------------------------------
@@ -148,6 +157,7 @@ export const API_KEY_PROVIDERS: readonly string[] = (() => {
148
157
  ...LLM_AND_SEARCH_API_KEY_PROVIDERS,
149
158
  ...sttApiKeyProviderNames(),
150
159
  ...catalogApiKeyNames(),
160
+ ...EMBEDDING_API_KEY_PROVIDERS,
151
161
  ]) {
152
162
  if (!seen.has(name)) {
153
163
  seen.add(name);