@vellumai/assistant 0.12.6-staging.3 → 0.12.7-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 (97) hide show
  1. package/.env.example +2 -0
  2. package/ARCHITECTURE.md +14 -14
  3. package/docs/architecture/integrations.md +5 -5
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  8. package/node_modules/@vellumai/gateway-client/src/__tests__/route-trust-class.test.ts +111 -0
  9. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +5 -2
  10. package/node_modules/@vellumai/gateway-client/src/index.ts +9 -0
  11. package/node_modules/@vellumai/gateway-client/src/route-trust-class.ts +94 -0
  12. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  13. package/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  14. package/openapi.yaml +153 -5
  15. package/package.json +1 -1
  16. package/src/__tests__/channel-policy.test.ts +7 -0
  17. package/src/__tests__/conversation-routes-disk-view.test.ts +141 -2
  18. package/src/__tests__/identity-routes.test.ts +1 -0
  19. package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
  20. package/src/__tests__/openai-provider.test.ts +27 -0
  21. package/src/__tests__/skill-execute-input.test.ts +11 -0
  22. package/src/__tests__/tool-approval-handler.test.ts +4 -0
  23. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
  24. package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
  25. package/src/agent/loop.ts +133 -12
  26. package/src/calls/__tests__/call-controller.test.ts +273 -1
  27. package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
  28. package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
  29. package/src/calls/call-constants.ts +10 -0
  30. package/src/calls/call-controller.ts +359 -25
  31. package/src/calls/media-stream-server.ts +18 -0
  32. package/src/calls/media-stream-stt-session.ts +30 -9
  33. package/src/channels/config.ts +8 -0
  34. package/src/cli/lib/bundled-marketplace.json +39 -0
  35. package/src/config/composer-preferences.test.ts +97 -0
  36. package/src/config/composer-preferences.ts +95 -0
  37. package/src/config/feature-flag-registry.json +16 -8
  38. package/src/config/schemas/llm.ts +1 -0
  39. package/src/contacts/types.ts +1 -0
  40. package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
  41. package/src/daemon/conversation-agent-loop.ts +3 -0
  42. package/src/daemon/conversation-messaging.ts +4 -0
  43. package/src/daemon/conversation-process.ts +15 -0
  44. package/src/daemon/conversation-queue-manager.ts +2 -0
  45. package/src/daemon/conversation.ts +2 -0
  46. package/src/daemon/handlers/shared.ts +13 -2
  47. package/src/daemon/message-types/sync.ts +1 -0
  48. package/src/daemon/process-message.ts +5 -0
  49. package/src/i18n/__tests__/i18n.test.ts +25 -0
  50. package/src/i18n/assistant-text.ts +18 -0
  51. package/src/i18n/index.ts +1 -0
  52. package/src/i18n/messages.ts +11 -0
  53. package/src/ipc/routes/__tests__/route-adapter.test.ts +40 -0
  54. package/src/ipc/routes/route-adapter.ts +9 -0
  55. package/src/notifications/README.md +1 -1
  56. package/src/notifications/signal.ts +4 -0
  57. package/src/persistence/conversation-key-store.ts +12 -11
  58. package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
  59. package/src/providers/inference/adapter-factory.ts +6 -0
  60. package/src/providers/ionet/client.test.ts +143 -0
  61. package/src/providers/ionet/client.ts +37 -0
  62. package/src/providers/model-catalog.ts +40 -0
  63. package/src/providers/openai/chat-completions-provider.ts +33 -2
  64. package/src/providers/retry.ts +10 -4
  65. package/src/providers/unparseable-tool-args.ts +29 -5
  66. package/src/runtime/__tests__/http-router-trust.test.ts +360 -0
  67. package/src/runtime/__tests__/shared-principal-lookup.test.ts +346 -0
  68. package/src/runtime/auth/__tests__/guard-tests.test.ts +51 -1
  69. package/src/runtime/auth/__tests__/route-policy.test.ts +85 -1
  70. package/src/runtime/auth/__tests__/scopes.test.ts +36 -0
  71. package/src/runtime/auth/route-policy.ts +44 -0
  72. package/src/runtime/auth/scopes.ts +15 -0
  73. package/src/runtime/auth/types.ts +1 -0
  74. package/src/runtime/channel-reply-delivery.ts +2 -0
  75. package/src/runtime/http-router.ts +61 -1
  76. package/src/runtime/http-types.ts +3 -0
  77. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
  78. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
  79. package/src/runtime/migrations/vbundle-builder.ts +19 -3
  80. package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
  81. package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
  82. package/src/runtime/migrations/vbundle-validator.ts +54 -1
  83. package/src/runtime/routes/composer-settings-routes.test.ts +71 -0
  84. package/src/runtime/routes/composer-settings-routes.ts +76 -0
  85. package/src/runtime/routes/conversation-routes.ts +54 -14
  86. package/src/runtime/routes/identity-routes.ts +2 -0
  87. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  88. package/src/runtime/routes/index.ts +2 -0
  89. package/src/runtime/routes/migration-routes.ts +3 -1
  90. package/src/runtime/routes/types.ts +4 -1
  91. package/src/runtime/shared-principal-lookup.ts +235 -0
  92. package/src/tools/host-terminal/host-shell.ts +4 -2
  93. package/src/tools/shared/shell-output.test.ts +42 -0
  94. package/src/tools/shared/shell-output.ts +27 -4
  95. package/src/tools/skills/execute.ts +10 -7
  96. package/src/tools/terminal/shell.ts +2 -0
  97. package/src/tools/tool-approval-handler.ts +9 -1
@@ -0,0 +1,18 @@
1
+ /** Resolve persisted daemon-authored assistant text at a presentation edge. */
2
+
3
+ import { DEFAULT_LOCALE, type SupportedLocale } from "./locales.js";
4
+ import { isMessageKey, t } from "./messages.js";
5
+
6
+ export function resolveStoredMessageText(
7
+ text: string,
8
+ locale: SupportedLocale = DEFAULT_LOCALE,
9
+ ): string {
10
+ const trimmed = text.trim();
11
+ if (!isMessageKey(trimmed)) {
12
+ return text;
13
+ }
14
+ const start = text.indexOf(trimmed);
15
+ return `${text.slice(0, start)}${t(trimmed, locale)}${text.slice(
16
+ start + trimmed.length,
17
+ )}`;
18
+ }
package/src/i18n/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { resolveStoredMessageText } from "./assistant-text.js";
1
2
  export {
2
3
  DEFAULT_LOCALE,
3
4
  isSupportedLocale,
@@ -16,6 +16,7 @@ import { DEFAULT_LOCALE, type SupportedLocale } from "./locales.js";
16
16
  export const MESSAGE_KEYS = {
17
17
  CONVERSATION_TITLE_GENERATING: "conversation.title.generating",
18
18
  CONVERSATION_TITLE_UNTITLED: "conversation.title.untitled",
19
+ AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP: "agentLoop.unparseableToolRetryStop",
19
20
  PLUGIN_MCP_OAUTH_CREDENTIALS_UNCHECKED:
20
21
  "plugin.uninstall.mcp_oauth_credentials_unchecked",
21
22
  } as const;
@@ -27,30 +28,40 @@ export const MESSAGE_CATALOGS: Record<
27
28
  Record<MessageKey, string>
28
29
  > = {
29
30
  en: {
31
+ "agentLoop.unparseableToolRetryStop":
32
+ "I stopped after repeated malformed tool calls to keep this conversation from growing indefinitely. Please try again, or switch models if the problem continues.",
30
33
  "conversation.title.generating": "Generating title...",
31
34
  "conversation.title.untitled": "Untitled Conversation",
32
35
  "plugin.uninstall.mcp_oauth_credentials_unchecked":
33
36
  "Credential storage is unavailable, so historical plugin MCP OAuth credentials could not be checked.",
34
37
  },
35
38
  es: {
39
+ "agentLoop.unparseableToolRetryStop":
40
+ "Me detuve después de varias llamadas de herramienta con formato incorrecto para evitar que esta conversación creciera indefinidamente. Inténtalo de nuevo o cambia de modelo si el problema continúa.",
36
41
  "conversation.title.generating": "Generando título...",
37
42
  "conversation.title.untitled": "Sin título",
38
43
  "plugin.uninstall.mcp_oauth_credentials_unchecked":
39
44
  "El almacenamiento de credenciales no está disponible, por lo que no se pudieron comprobar las credenciales históricas de OAuth de MCP del plugin.",
40
45
  },
41
46
  ru: {
47
+ "agentLoop.unparseableToolRetryStop":
48
+ "Выполнение остановлено после нескольких некорректных вызовов инструментов, чтобы этот разговор не рос бесконечно. Попробуйте ещё раз или смените модель, если проблема не исчезнет.",
42
49
  "conversation.title.generating": "Создание названия...",
43
50
  "conversation.title.untitled": "Без названия",
44
51
  "plugin.uninstall.mcp_oauth_credentials_unchecked":
45
52
  "Хранилище учетных данных недоступно, поэтому не удалось проверить сохраненные учетные данные OAuth MCP плагина.",
46
53
  },
47
54
  zh: {
55
+ "agentLoop.unparseableToolRetryStop":
56
+ "在工具调用多次格式错误后,我已停止运行,以免此对话无限增长。请重试;如果问题仍然存在,请切换模型。",
48
57
  "conversation.title.generating": "标题生成中...",
49
58
  "conversation.title.untitled": "无标题",
50
59
  "plugin.uninstall.mcp_oauth_credentials_unchecked":
51
60
  "凭据存储不可用,因此无法检查该插件之前的 MCP OAuth 凭据。",
52
61
  },
53
62
  "zh-TW": {
63
+ "agentLoop.unparseableToolRetryStop":
64
+ "在工具呼叫多次格式錯誤後,我已停止執行,以免此對話無限增長。請重試;如果問題仍然存在,請切換模型。",
54
65
  "conversation.title.generating": "標題產生中...",
55
66
  "conversation.title.untitled": "未命名",
56
67
  "plugin.uninstall.mcp_oauth_credentials_unchecked":
@@ -41,6 +41,7 @@ interface SchemaEntry {
41
41
  policy: {
42
42
  requiredScopes: string[];
43
43
  allowedPrincipalTypes: string[];
44
+ allowedTrustClasses: string[];
44
45
  } | null;
45
46
  }
46
47
 
@@ -130,6 +131,7 @@ describe("routeDefinitionsToIpcMethods — schema shape", () => {
130
131
  .object({
131
132
  requiredScopes: z.array(z.string()),
132
133
  allowedPrincipalTypes: z.array(z.string()),
134
+ allowedTrustClasses: z.array(z.string()),
133
135
  })
134
136
  .nullable(),
135
137
  });
@@ -241,4 +243,42 @@ describe("routeDefinitionsToIpcMethods — policy serialization", () => {
241
243
  expect(schema[0].policy?.requiredScopes).toEqual(["settings.write"]);
242
244
  expect(schema[1].policy?.requiredScopes).toEqual(["settings.write"]);
243
245
  });
246
+
247
+ test("a policy declaring no trust classes ships guardian only", async () => {
248
+ // The gateway is the sole policy check on the IPC path, so the
249
+ // effective value must cross the wire rather than be re-derived
250
+ // there from an absent field.
251
+ const routes: RouteDefinition[] = [
252
+ defineRoute({
253
+ operationId: "settings_read",
254
+ endpoint: "settings",
255
+ policy: {
256
+ requiredScopes: ["settings.read"],
257
+ allowedPrincipalTypes: ["actor"],
258
+ },
259
+ }),
260
+ ];
261
+ const schema = await getSchema(routes);
262
+ expect(schema[0].policy?.allowedTrustClasses).toEqual(["guardian"]);
263
+ });
264
+
265
+ test("declared trust classes ship verbatim", async () => {
266
+ const routes: RouteDefinition[] = [
267
+ defineRoute({
268
+ operationId: "chat_send",
269
+ endpoint: "chat",
270
+ method: "POST",
271
+ policy: {
272
+ requiredScopes: ["chat.write"],
273
+ allowedPrincipalTypes: ["actor"],
274
+ allowedTrustClasses: ["guardian", "trusted_contact"],
275
+ },
276
+ }),
277
+ ];
278
+ const schema = await getSchema(routes);
279
+ expect(schema[0].policy?.allowedTrustClasses).toEqual([
280
+ "guardian",
281
+ "trusted_contact",
282
+ ]);
283
+ });
244
284
  });
@@ -8,6 +8,7 @@
8
8
  * RouteDefinition; no lookup or derivation happens here.
9
9
  */
10
10
 
11
+ import { GUARDIAN_ONLY } from "../../runtime/auth/route-policy.js";
11
12
  import type { RouteDefinition } from "../../runtime/routes/types.js";
12
13
 
13
14
  function isIpcEligible(r: RouteDefinition): boolean {
@@ -22,6 +23,10 @@ function isIpcEligible(r: RouteDefinition): boolean {
22
23
  * unprotected (e.g. health, debug). The gateway respects that and
23
24
  * skips enforcement. `policy: { ... }` carries the same scopes /
24
25
  * principal types the daemon's HTTP path enforces via `enforcePolicy()`.
26
+ *
27
+ * `allowedTrustClasses` is always present on a non-null policy, resolved
28
+ * to {@link GUARDIAN_ONLY} when the route declares none, so the wire
29
+ * carries the effective value and no default is duplicated gateway-side.
25
30
  */
26
31
  interface IpcRouteSchemaEntry {
27
32
  operationId: string;
@@ -30,6 +35,7 @@ interface IpcRouteSchemaEntry {
30
35
  policy: {
31
36
  requiredScopes: string[];
32
37
  allowedPrincipalTypes: string[];
38
+ allowedTrustClasses: string[];
33
39
  } | null;
34
40
  }
35
41
 
@@ -44,6 +50,9 @@ function toSchemaEntry(r: RouteDefinition): IpcRouteSchemaEntry {
44
50
  // shape doesn't carry the `Scope` / `PrincipalType` narrowing.
45
51
  requiredScopes: [...r.policy.requiredScopes],
46
52
  allowedPrincipalTypes: [...r.policy.allowedPrincipalTypes],
53
+ allowedTrustClasses: [
54
+ ...(r.policy.allowedTrustClasses ?? GUARDIAN_ONLY),
55
+ ],
47
56
  }
48
57
  : null,
49
58
  };
@@ -216,7 +216,7 @@ Each policy defines:
216
216
  | `start_new_conversation` | Pairs the delivery with a new or reused conversation, or appends to the producing one (see Conversation Pairing below). | `vellum` |
217
217
  | `continue_existing_conversation` | Resolves the destination chat's home conversation, creating one on first delivery. The delivered post is written there only once the channel acknowledges it. | `telegram`, `slack`, `discord`, and the non-deliverable `whatsapp`, `email`, `a2a`, `plugin` |
218
218
  | `push_only` | Pairs nothing. The push deep-links through the vellum delivery's conversation. | `platform` |
219
- | `not_deliverable` | Channel cannot receive notifications. Pairing returns null IDs. | `phone` |
219
+ | `not_deliverable` | Channel cannot receive notifications. Pairing returns null IDs. | `phone`, `vellum-shared` |
220
220
 
221
221
  ### Helper Functions
222
222
 
@@ -27,6 +27,10 @@ export const NOTIFICATION_SOURCE_CHANNELS = [
27
27
  { id: "a2a", description: "Agent-to-agent protocol channel" },
28
28
  { id: "discord", description: "Discord channel" },
29
29
  { id: "plugin", description: "Channel brought by an installed plugin" },
30
+ {
31
+ id: "vellum-shared",
32
+ description: "Conversation a guardian shared with a contact",
33
+ },
30
34
  { id: "scheduler", description: "Scheduled task runner (reminders, cron)" },
31
35
  { id: "watcher", description: "File/event watcher subsystem" },
32
36
  ] as const;
@@ -36,6 +36,13 @@ export function _resetFirstConversationSeenForTesting(): void {
36
36
  firstConversationSeen = false;
37
37
  }
38
38
 
39
+ export function recordStandardConversationCreated(): void {
40
+ if (firstConversationSeen) {
41
+ cleanupBootstrapFiles("new conversation created after onboarding");
42
+ }
43
+ firstConversationSeen = true;
44
+ }
45
+
39
46
  export interface ConversationKeyMapping {
40
47
  id: string;
41
48
  conversationKey: string;
@@ -174,6 +181,8 @@ export function getOrCreateConversation(
174
181
  * what every caller did before this option existed.
175
182
  */
176
183
  origin?: ChannelId;
184
+ /** The caller records creation after required setup succeeds. */
185
+ deferBootstrapCleanup?: boolean;
177
186
  },
178
187
  ): {
179
188
  conversationId: string;
@@ -233,17 +242,6 @@ export function getOrCreateConversation(
233
242
  };
234
243
  }
235
244
 
236
- // The first standard conversation is the onboarding one, so BOOTSTRAP.md
237
- // survives its whole duration and any later standard create means
238
- // onboarding is over. Background rows stay inert: a hidden side thread
239
- // minted before the user's first visible chat must not consume that slot.
240
- if (conversationType === "standard") {
241
- if (firstConversationSeen) {
242
- cleanupBootstrapFiles("new conversation created after onboarding");
243
- }
244
- firstConversationSeen = true;
245
- }
246
-
247
245
  const now = Date.now();
248
246
  const conversationId = uuid();
249
247
  const customTitle = opts?.title?.trim();
@@ -300,6 +298,9 @@ export function getOrCreateConversation(
300
298
 
301
299
  if (result.created) {
302
300
  initConversationDir(result.conversation);
301
+ if (conversationType === "standard" && !opts?.deferBootstrapCleanup) {
302
+ recordStandardConversationCreated();
303
+ }
303
304
  // Attribution for every key-materialized conversation: this is the single
304
305
  // choke point through which all unseen-key creations flow, and the key
305
306
  // shape + caller frames identify the entry point from the log alone. The
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
+ boundUnparseableToolArgs,
5
+ boundUnparseableToolArgsRaw,
4
6
  isUnparseableToolArgs,
5
7
  unparseableToolArgsMessage,
6
8
  wrapUnparseableToolArgs,
@@ -31,6 +33,29 @@ describe("unparseable tool args marker", () => {
31
33
  expect(isUnparseableToolArgs({})).toBe(false);
32
34
  });
33
35
 
36
+ test("bounds long raw args while preserving the marker shape", () => {
37
+ const raw = "a".repeat(1_000);
38
+ const bounded = boundUnparseableToolArgs(wrapUnparseableToolArgs(raw));
39
+
40
+ expect(isUnparseableToolArgs(bounded)).toBe(true);
41
+ expect(bounded).toEqual({ _raw: "a".repeat(200) + "…" });
42
+ });
43
+
44
+ test("bounds raw diagnostic copies with the same preview contract", () => {
45
+ expect(boundUnparseableToolArgsRaw("a".repeat(1_000))).toBe(
46
+ "a".repeat(200) + "…",
47
+ );
48
+ expect(boundUnparseableToolArgsRaw("short")).toBe("short");
49
+ });
50
+
51
+ test("leaves short markers and ordinary input unchanged", () => {
52
+ const marker = wrapUnparseableToolArgs("short");
53
+ const ordinary = { command: "ls" };
54
+
55
+ expect(boundUnparseableToolArgs(marker)).toBe(marker);
56
+ expect(boundUnparseableToolArgs(ordinary)).toBe(ordinary);
57
+ });
58
+
34
59
  test("message includes tool name, raw preview, and retry instruction", () => {
35
60
  const msg = unparseableToolArgsMessage("ui_show", '{"surface_type": ');
36
61
  expect(msg).toContain('"ui_show"');
@@ -35,6 +35,7 @@ import { AtlasCloudProvider } from "../atlascloud/client.js";
35
35
  import { BasetenProvider } from "../baseten/client.js";
36
36
  import { FireworksProvider } from "../fireworks/client.js";
37
37
  import { GeminiProvider } from "../gemini/client.js";
38
+ import { IonetProvider } from "../ionet/client.js";
38
39
  import { JevProvider } from "../jev/client.js";
39
40
  import { MinimaxProvider } from "../minimax/client.js";
40
41
  import { PROVIDER_CATALOG } from "../model-catalog.js";
@@ -206,6 +207,11 @@ const ADAPTER_FACTORIES: Record<string, AdapterFactory> = {
206
207
  streamTimeoutMs,
207
208
  ...(baseURL ? { baseURL } : {}),
208
209
  }),
210
+ ionet: ({ apiKey, model, streamTimeoutMs, baseURL }) =>
211
+ new IonetProvider(apiKey, model, {
212
+ streamTimeoutMs,
213
+ ...(baseURL ? { baseURL } : {}),
214
+ }),
209
215
  typesafe: ({ apiKey, model, streamTimeoutMs, baseURL }) =>
210
216
  new JevProvider(apiKey, model, {
211
217
  streamTimeoutMs,
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Verifies the IO Intelligence (io.net) provider wiring: the SDK client must
3
+ * resolve to the documented IO Intelligence chat-completions endpoint unless
4
+ * an explicit override is supplied (custom-connection / test usage).
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+
9
+ import { IonetProvider } from "./client.js";
10
+
11
+ type MockChunk = {
12
+ choices: Array<{
13
+ delta: { content?: string | null };
14
+ finish_reason?: string | null;
15
+ }>;
16
+ usage?: { prompt_tokens: number; completion_tokens: number };
17
+ };
18
+
19
+ const OK_CHUNKS: MockChunk[] = [
20
+ {
21
+ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
22
+ usage: { prompt_tokens: 1, completion_tokens: 1 },
23
+ },
24
+ ];
25
+
26
+ function makeStream(chunks: MockChunk[]): AsyncIterable<MockChunk> {
27
+ return {
28
+ async *[Symbol.asyncIterator]() {
29
+ for (const c of chunks) {
30
+ yield c;
31
+ }
32
+ },
33
+ };
34
+ }
35
+
36
+ function clientOf(provider: IonetProvider) {
37
+ return (
38
+ provider as unknown as {
39
+ client: {
40
+ baseURL: string;
41
+ chat: {
42
+ completions: {
43
+ create: (params: unknown) => Promise<AsyncIterable<MockChunk>>;
44
+ };
45
+ };
46
+ };
47
+ }
48
+ ).client;
49
+ }
50
+
51
+ describe("IonetProvider", () => {
52
+ test("defaults the SDK client to the IO Intelligence endpoint", async () => {
53
+ const provider = new IonetProvider(
54
+ "test-key",
55
+ "meta-llama/Llama-3.3-70B-Instruct",
56
+ );
57
+
58
+ const client = clientOf(provider);
59
+ expect(client.baseURL).toBe("https://api.intelligence.io.solutions/api/v1");
60
+
61
+ // The same base URL must be reported on the response, read off the live
62
+ // client instance rather than a constant.
63
+ client.chat.completions.create = async () => makeStream(OK_CHUNKS);
64
+ const response = await provider.sendMessage([
65
+ { role: "user", content: [{ type: "text", text: "hi" }] },
66
+ ]);
67
+ expect(response.resolvedEndpoint).toBe(
68
+ "https://api.intelligence.io.solutions/api/v1",
69
+ );
70
+ });
71
+
72
+ test("sends tool_choice auto when tools are offered without an explicit choice", async () => {
73
+ // io.net defaults an unspecified tool_choice to "none" (not OpenAI's
74
+ // "auto"), so the provider must send the explicit default or the model
75
+ // could never invoke an offered tool.
76
+ const provider = new IonetProvider(
77
+ "test-key",
78
+ "meta-llama/Llama-3.3-70B-Instruct",
79
+ );
80
+ const client = clientOf(provider);
81
+
82
+ let seenParams:
83
+ | {
84
+ tools?: unknown;
85
+ tool_choice?: unknown;
86
+ }
87
+ | undefined;
88
+ client.chat.completions.create = async (params) => {
89
+ seenParams = params as { tools?: unknown; tool_choice?: unknown };
90
+ return makeStream(OK_CHUNKS);
91
+ };
92
+ await provider.sendMessage(
93
+ [{ role: "user", content: [{ type: "text", text: "hi" }] }],
94
+ {
95
+ tools: [
96
+ {
97
+ name: "get_weather",
98
+ description: "Get the weather",
99
+ input_schema: { type: "object", properties: {} },
100
+ },
101
+ ],
102
+ },
103
+ );
104
+ expect(Array.isArray(seenParams?.tools)).toBe(true);
105
+ expect(seenParams?.tool_choice).toBe("auto");
106
+
107
+ // An explicit caller choice still wins.
108
+ let forcedParams: { tool_choice?: unknown } | undefined;
109
+ client.chat.completions.create = async (params) => {
110
+ forcedParams = params as { tool_choice?: unknown };
111
+ return makeStream(OK_CHUNKS);
112
+ };
113
+ await provider.sendMessage(
114
+ [{ role: "user", content: [{ type: "text", text: "hi" }] }],
115
+ {
116
+ tools: [
117
+ {
118
+ name: "get_weather",
119
+ description: "Get the weather",
120
+ input_schema: { type: "object", properties: {} },
121
+ },
122
+ ],
123
+ config: { tool_choice: { type: "none" } },
124
+ },
125
+ );
126
+ expect(forcedParams?.tool_choice).toBe("none");
127
+ });
128
+
129
+ test("honours an explicit baseURL override", async () => {
130
+ const provider = new IonetProvider("test-key", "test-model", {
131
+ baseURL: "https://inference.example.test/v1",
132
+ });
133
+
134
+ const client = clientOf(provider);
135
+ expect(client.baseURL).toBe("https://inference.example.test/v1");
136
+
137
+ client.chat.completions.create = async () => makeStream(OK_CHUNKS);
138
+ const response = await provider.sendMessage([
139
+ { role: "user", content: [{ type: "text", text: "hi" }] },
140
+ ]);
141
+ expect(response.resolvedEndpoint).toBe("https://inference.example.test/v1");
142
+ });
143
+ });
@@ -0,0 +1,37 @@
1
+ import { OpenAIChatCompletionsProvider } from "../openai/chat-completions-provider.js";
2
+
3
+ export interface IonetProviderOptions {
4
+ apiKey?: string;
5
+ baseURL?: string;
6
+ streamTimeoutMs?: number;
7
+ }
8
+
9
+ const DEFAULT_IONET_BASE_URL = "https://api.intelligence.io.solutions/api/v1";
10
+
11
+ /**
12
+ * IO Intelligence (io.net) serves an OpenAI chat-completions API with
13
+ * Hugging Face-style `org/name` model ids, so it rides the shared
14
+ * chat-completions client with its own default base URL.
15
+ */
16
+ export class IonetProvider extends OpenAIChatCompletionsProvider {
17
+ constructor(
18
+ apiKey: string,
19
+ model: string,
20
+ options: IonetProviderOptions = {},
21
+ ) {
22
+ super(apiKey, model, {
23
+ baseURL: options.baseURL?.trim() || DEFAULT_IONET_BASE_URL,
24
+ providerName: "ionet",
25
+ providerLabel: "IO Intelligence",
26
+ streamTimeoutMs: options.streamTimeoutMs,
27
+ // IO Intelligence serves DeepSeek- and GLM-style thinking models whose
28
+ // reasoning arrives as `reasoning_content`; replay it there so
29
+ // follow-up requests that include tools stay acceptable upstream.
30
+ assistantReasoningField: "reasoning_content",
31
+ // io.net defaults an unspecified `tool_choice` to "none" (unlike
32
+ // OpenAI's "auto"), so tools offered without an explicit choice must
33
+ // still send one or the model could never invoke them.
34
+ defaultToolChoiceAuto: true,
35
+ });
36
+ }
37
+ }
@@ -2746,6 +2746,46 @@ const RAW_PROVIDER_CATALOG: ProviderCatalogEntry[] = [
2746
2746
  apiKeyUrl: "https://app.baseten.co/settings/api_keys",
2747
2747
  apiKeyPlaceholder: "Your Baseten API key",
2748
2748
  },
2749
+ {
2750
+ id: "ionet",
2751
+ displayName: "IO Intelligence",
2752
+ subtitle:
2753
+ "Open models served by IO Intelligence, io.net's inference platform (OpenAI-compatible). Requires an IO Intelligence API key.",
2754
+ setupMode: "api-key",
2755
+ setupHint:
2756
+ "Enter your IO Intelligence API key to enable IO Intelligence models.",
2757
+ envVar: "IONET_API_KEY",
2758
+ credentialsGuide: {
2759
+ description: "Sign in to io.net and create an IO Intelligence API key.",
2760
+ url: "https://io.net/docs/guides/intelligence/api-keys-and-secrets",
2761
+ linkLabel: "Open io.net",
2762
+ },
2763
+ // Model ids are Hugging Face-style `org/name`. Context windows and tool
2764
+ // support come from the endpoint's live `GET /models` catalog, and every
2765
+ // entry is accessible at the basic access tier.
2766
+ models: [
2767
+ {
2768
+ id: "meta-llama/Llama-3.3-70B-Instruct",
2769
+ displayName: "Llama 3.3 70B Instruct",
2770
+ contextWindowTokens: 128000,
2771
+ defaultContextWindowTokens: 128000,
2772
+ maxOutputTokens: 4096,
2773
+ supportsToolUse: true,
2774
+ },
2775
+ {
2776
+ id: "openai/gpt-oss-20b",
2777
+ displayName: "GPT-OSS 20B",
2778
+ contextWindowTokens: 64000,
2779
+ defaultContextWindowTokens: 64000,
2780
+ maxOutputTokens: 32768,
2781
+ supportsThinking: true,
2782
+ supportsToolUse: true,
2783
+ },
2784
+ ],
2785
+ defaultModel: "meta-llama/Llama-3.3-70B-Instruct",
2786
+ apiKeyUrl: "https://io.net/docs/guides/intelligence/api-keys-and-secrets",
2787
+ apiKeyPlaceholder: "Your IO Intelligence API key",
2788
+ },
2749
2789
  {
2750
2790
  id: "poolside",
2751
2791
  displayName: "Poolside",
@@ -39,6 +39,7 @@ import {
39
39
  extractOverflowTokensFromMessage,
40
40
  } from "../types.js";
41
41
  import {
42
+ boundUnparseableToolArgsRaw,
42
43
  isUnparseableToolArgs,
43
44
  wrapUnparseableToolArgs,
44
45
  } from "../unparseable-tool-args.js";
@@ -212,6 +213,12 @@ export interface OpenAIChatCompletionsProviderOptions {
212
213
  * (Fireworks, Together) keep sending `none` / forced choices. Enabled for
213
214
  * the generic `openai-compatible` adapter, whose upstream is unknown. */
214
215
  omitToolChoiceWhenReasoning?: boolean;
216
+ /** Send `tool_choice: "auto"` whenever tools are offered and the caller
217
+ * left the choice unspecified. Strict OpenAI-compatible upstreams default
218
+ * an unspecified `tool_choice` to "none" (io.net documents exactly that),
219
+ * which would leave the model unable to call any tool; OpenAI's own
220
+ * default is "auto", so this option only exists for upstreams that differ. */
221
+ defaultToolChoiceAuto?: boolean;
215
222
  /** Wire field for the output-token limit. OpenAI and OpenAI-compatible
216
223
  * backends use `max_completion_tokens`. OpenRouter defaults to
217
224
  * `max_tokens` because its parameter router matches that key on
@@ -864,6 +871,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
864
871
  private coerceObjectArgsToJsonString: boolean;
865
872
  private salvageXmlToolCalls: boolean;
866
873
  private omitToolChoiceWhenReasoning: boolean;
874
+ private defaultToolChoiceAuto: boolean;
867
875
  private outputTokenLimitField: "max_completion_tokens" | "max_tokens";
868
876
 
869
877
  constructor(
@@ -896,6 +904,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
896
904
  options.salvageXmlToolCalls ?? shouldSalvageXmlToolCalls(model);
897
905
  this.omitToolChoiceWhenReasoning =
898
906
  options.omitToolChoiceWhenReasoning ?? false;
907
+ this.defaultToolChoiceAuto = options.defaultToolChoiceAuto ?? false;
899
908
  this.outputTokenLimitField =
900
909
  options.outputTokenLimitField ?? "max_completion_tokens";
901
910
  }
@@ -958,6 +967,15 @@ export class OpenAIChatCompletionsProvider implements Provider {
958
967
  maxTokens;
959
968
  }
960
969
 
970
+ // Per-conversation prompt-cache key (OpenAI Chat Completions
971
+ // `prompt_cache_key`). `RetryProvider` stamps it from the durable
972
+ // conversation id for providers in PROMPT_CACHE_KEY_PROVIDERS; omitted
973
+ // from the wire when absent.
974
+ const promptCacheKey = configObj?.promptCacheKey;
975
+ if (typeof promptCacheKey === "string" && promptCacheKey.length > 0) {
976
+ params.prompt_cache_key = promptCacheKey;
977
+ }
978
+
961
979
  // Profile-scoped token biasing (e.g. the `suppress-cjk` preset). Resolved
962
980
  // and gated to this provider family upstream in `RetryProvider`.
963
981
  if (logitBias) {
@@ -1028,7 +1046,12 @@ export class OpenAIChatCompletionsProvider implements Provider {
1028
1046
  // wire. Catalog providers that honor `none` / forced choices still
1029
1047
  // receive them; the generic openai-compatible adapter drops every
1030
1048
  // explicit value in thinking mode via `omitToolChoiceWhenReasoning`.
1031
- const toolChoice = mapNeutralToolChoice(configObj?.tool_choice);
1049
+ const callerToolChoice = mapNeutralToolChoice(configObj?.tool_choice);
1050
+ const toolChoice =
1051
+ callerToolChoice ??
1052
+ (this.defaultToolChoiceAuto
1053
+ ? ("auto" as OpenAI.Chat.Completions.ChatCompletionToolChoiceOption)
1054
+ : undefined);
1032
1055
  xmlToolCallSalvageEnabled =
1033
1056
  this.salvageXmlToolCalls && toolChoice !== "none";
1034
1057
  if (toolChoice !== undefined) {
@@ -1149,6 +1172,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
1149
1172
  id: string;
1150
1173
  name: string;
1151
1174
  args: string;
1175
+ inspectableArgs?: string;
1152
1176
  providerMetadata?: ToolUseContent["providerMetadata"];
1153
1177
  }
1154
1178
  >();
@@ -1450,6 +1474,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
1450
1474
  input = JSON.parse(tc.args);
1451
1475
  } catch {
1452
1476
  input = wrapUnparseableToolArgs(tc.args);
1477
+ tc.inspectableArgs = boundUnparseableToolArgsRaw(tc.args);
1453
1478
  }
1454
1479
  const objectKeys = coercedObjectKeys.get(tc.name);
1455
1480
  if (objectKeys && !isUnparseableToolArgs(input)) {
@@ -1457,6 +1482,9 @@ export class OpenAIChatCompletionsProvider implements Provider {
1457
1482
  input = decoded.failedKey
1458
1483
  ? wrapUnparseableToolArgs(tc.args)
1459
1484
  : decoded.input;
1485
+ if (decoded.failedKey) {
1486
+ tc.inspectableArgs = boundUnparseableToolArgsRaw(tc.args);
1487
+ }
1460
1488
  }
1461
1489
  content.push({
1462
1490
  type: "tool_use",
@@ -1484,7 +1512,10 @@ export class OpenAIChatCompletionsProvider implements Provider {
1484
1512
  {
1485
1513
  id: tc.id,
1486
1514
  type: "function",
1487
- function: { name: tc.name, arguments: tc.args },
1515
+ function: {
1516
+ name: tc.name,
1517
+ arguments: tc.inspectableArgs ?? tc.args,
1518
+ },
1488
1519
  },
1489
1520
  thoughtSignatureFromToolUseMetadata(
1490
1521
  tc.providerMetadata,
@@ -78,10 +78,16 @@ const USAGE_ATTRIBUTION_HEADER_NAMES = {
78
78
  subagentSpawnMode: "X-Vellum-Subagent-Spawn-Mode",
79
79
  } as const;
80
80
 
81
- /** Providers whose transports consume `promptCacheKey` (OpenAI Responses
82
- * `prompt_cache_key`); `RetryProvider` derives it from `selectionSeed` for
83
- * these only. */
84
- const PROMPT_CACHE_KEY_PROVIDERS = new Set(["openai", "openrouter"]);
81
+ /** Providers whose transports consume `promptCacheKey` (OpenAI Responses /
82
+ * Chat Completions `prompt_cache_key`); `RetryProvider` derives it from
83
+ * `selectionSeed` for these only. `openai-compatible` is included so
84
+ * self-hosted bridges (e.g. a Claude Code shim) receive a stable
85
+ * per-conversation key and can pin server-side sessions / prompt caches to it. */
86
+ const PROMPT_CACHE_KEY_PROVIDERS = new Set([
87
+ "openai",
88
+ "openrouter",
89
+ "openai-compatible",
90
+ ]);
85
91
 
86
92
  /** Providers that support the `effort` config (extended thinking / reasoning). */
87
93
  const EFFORT_SUPPORTED_PROVIDERS = new Set([