@vellumai/assistant 0.12.3 → 0.12.4-staging.2

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 (103) hide show
  1. package/Dockerfile +7 -0
  2. package/README.md +12 -12
  3. package/docker/mousepad/90-vellum.gschema.override +4 -0
  4. package/openapi.yaml +9 -3
  5. package/package.json +1 -1
  6. package/src/__tests__/approval-routes-http.test.ts +4 -2
  7. package/src/__tests__/call-routes-http.test.ts +4 -2
  8. package/src/__tests__/config-loader-backfill.test.ts +1 -0
  9. package/src/__tests__/conversation-resurface-done.test.ts +289 -0
  10. package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
  11. package/src/__tests__/gemini-provider.test.ts +31 -0
  12. package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
  13. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  14. package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
  15. package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
  16. package/src/__tests__/provider-error-scenarios.test.ts +46 -0
  17. package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
  18. package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
  19. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  20. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
  21. package/src/__tests__/send-endpoint-busy.test.ts +4 -2
  22. package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
  23. package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
  24. package/src/api/requests/dictation.ts +2 -2
  25. package/src/calls/__tests__/typesafe-noul.test.ts +66 -0
  26. package/src/calls/typesafe-noul.ts +8 -2
  27. package/src/calls/voice-escalation-judge.ts +5 -4
  28. package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
  29. package/src/cli/lib/bundled-marketplace.json +4 -4
  30. package/src/config/__tests__/default-profile-catalog.test.ts +33 -1
  31. package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
  32. package/src/config/call-site-defaults.ts +11 -11
  33. package/src/config/default-profile-catalog.ts +88 -18
  34. package/src/config/default-profile-names.ts +35 -0
  35. package/src/config/feature-flag-registry.json +8 -8
  36. package/src/config/inference-profile-validation.ts +7 -6
  37. package/src/config/llm-resolver.ts +16 -0
  38. package/src/config/profile-text-generation.test.ts +9 -0
  39. package/src/config/schemas/call-site-catalog.ts +1 -1
  40. package/src/config/schemas/llm.ts +54 -9
  41. package/src/config/sidebar-done-gate.ts +27 -0
  42. package/src/daemon/conversation-slash.ts +2 -2
  43. package/src/daemon/conversation-turn-finalize.ts +12 -0
  44. package/src/daemon/dictation-text-processing.ts +4 -4
  45. package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
  46. package/src/daemon/handlers/config-channels.ts +3 -1
  47. package/src/daemon/handlers/conversation-history.ts +11 -1
  48. package/src/daemon/handlers/conversations.ts +118 -10
  49. package/src/desktop/desktop-browser-endpoint.ts +1 -0
  50. package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
  51. package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
  52. package/src/permissions/question-prompter.ts +14 -4
  53. package/src/persistence/conversation-crud.ts +114 -3
  54. package/src/persistence/conversation-queries.ts +48 -3
  55. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +157 -32
  56. package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
  57. package/src/persistence/embeddings/embedding-backend.ts +23 -41
  58. package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
  59. package/src/persistence/embeddings/embedding-gemini.ts +3 -1
  60. package/src/persistence/embeddings/embedding-local.ts +72 -17
  61. package/src/persistence/embeddings/embedding-types.ts +0 -19
  62. package/src/plugin-api/model-profiles.ts +5 -3
  63. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
  64. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
  65. package/src/providers/gemini/client.ts +17 -1
  66. package/src/providers/malformed-tool-call.ts +55 -0
  67. package/src/providers/model-catalog.ts +23 -4
  68. package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
  69. package/src/providers/openai/chat-completions-provider.ts +30 -0
  70. package/src/providers/platform-proxy/constants.ts +5 -0
  71. package/src/providers/retry.ts +68 -36
  72. package/src/providers/vellum-model-routing.test.ts +1 -0
  73. package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
  74. package/src/runtime/__tests__/question-resolution.test.ts +32 -0
  75. package/src/runtime/agent-wake.ts +9 -2
  76. package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
  77. package/src/runtime/auth/middleware.ts +15 -11
  78. package/src/runtime/middleware/auth.ts +7 -1
  79. package/src/runtime/pending-interactions.ts +8 -8
  80. package/src/runtime/question-resolution.ts +54 -8
  81. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
  82. package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
  83. package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
  84. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
  85. package/src/runtime/routes/conversation-list-routes.ts +16 -14
  86. package/src/runtime/routes/conversation-query-routes.ts +4 -0
  87. package/src/runtime/routes/conversation-routes.ts +4 -2
  88. package/src/runtime/routes/diagnostics-routes.ts +66 -229
  89. package/src/runtime/routes/global-search-routes.ts +22 -7
  90. package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
  91. package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
  92. package/src/runtime/sync/resource-sync-events.ts +11 -0
  93. package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
  94. package/src/runtime/sync/worker-daemon-notify.ts +44 -0
  95. package/src/runtime/verification-outbound-actions.ts +2 -2
  96. package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
  97. package/src/tools/ask-question/ask-question-tool.ts +12 -1
  98. package/src/tools/workflows/manage-workflows.ts +4 -2
  99. package/src/util/worker-pipe.test.ts +100 -0
  100. package/src/util/worker-pipe.ts +44 -0
  101. package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
  102. package/src/workspace/migrations/registry.ts +2 -0
  103. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
@@ -71,7 +71,8 @@ export class GeminiEmbeddingBackend implements EmbeddingBackend {
71
71
  }
72
72
 
73
73
  /**
74
- * Embed `inputs` in order. Runs of text inputs go through
74
+ * Embed `inputs` in order. Managed requests use single `embedContent`
75
+ * calls because Vertex does not expose the batch route. Direct text runs use
75
76
  * `batchEmbedContents`, {@link GEMINI_EMBED_BATCH_SIZE} texts per round
76
77
  * trip, so a corpus re-embed costs one request per hundred sections rather
77
78
  * than one per section; a lone text and every multimodal input take the
@@ -99,6 +100,7 @@ export class GeminiEmbeddingBackend implements EmbeddingBackend {
99
100
  // Gather the run of text inputs starting here, up to one batch.
100
101
  let end = i;
101
102
  while (
103
+ !this.managed &&
102
104
  !this.batchRouteUnavailable &&
103
105
  end < normalized.length &&
104
106
  end - i < GEMINI_EMBED_BATCH_SIZE &&
@@ -7,6 +7,8 @@ import {
7
7
  } from "node:fs";
8
8
  import { join } from "node:path";
9
9
 
10
+ import type { Subprocess } from "bun";
11
+
10
12
  import { getIsContainerized } from "../../config/env-registry.js";
11
13
  import { getLogger } from "../../util/logger.js";
12
14
  import {
@@ -22,6 +24,7 @@ import {
22
24
  listWorkerProcesses,
23
25
  pid1OwnsWorkers,
24
26
  } from "../../util/worker-ownership.js";
27
+ import { writeWorkerLine } from "../../util/worker-pipe.js";
25
28
  import { EmbeddingRuntimeManager } from "./embedding-runtime-manager.js";
26
29
  import {
27
30
  type EmbeddingBackend,
@@ -106,9 +109,7 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
106
109
  readonly provider = "local" as const;
107
110
  readonly model: string;
108
111
 
109
- // Subprocess — typed loosely to avoid coupling to Bun's Subprocess generics
110
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
- private workerProc: any = null;
112
+ private workerProc: Subprocess<"pipe", "pipe", "pipe"> | null = null;
112
113
  private stdoutBuffer = "";
113
114
  private requestCounter = 0;
114
115
  private pendingRequests = new Map<
@@ -119,7 +120,10 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
119
120
  >();
120
121
  private stdoutReaderActive = false;
121
122
  private activeEmbeds = 0;
123
+ /** Release the worker once idle. {@link forModel} withdraws the request. */
122
124
  private disposeRequested = false;
125
+ /** Set by {@link shutdown}. The process is exiting, so nothing reopens this. */
126
+ private processExiting = false;
123
127
 
124
128
  private readonly initGuard = new PromiseGuard<void>();
125
129
  private initInFlight: Promise<void> | null = null;
@@ -130,7 +134,67 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
130
134
  /** Overridable so tests can exercise the escalation path without the wait. */
131
135
  private terminateGraceMs = WORKER_TERMINATE_GRACE_MS;
132
136
 
133
- constructor(model: string) {
137
+ /**
138
+ * Every backend this process has handed out, one per model.
139
+ *
140
+ * {@link reclaimOwnedWorkers} terminates a same-model worker parented to this
141
+ * process that the instance holds no handle for. That is sound only while no
142
+ * other instance in this process can hold that worker, so instances come from
143
+ * {@link forModel} and are never constructed by callers.
144
+ *
145
+ * This is also the complete list of workers the process owns. The backend
146
+ * cache is not: it forgets a backend whose disposal is still pending.
147
+ */
148
+ private static readonly byModel = new Map<string, LocalEmbeddingBackend>();
149
+
150
+ /**
151
+ * The backend for `model`. A pending {@link dispose} is withdrawn, so embeds
152
+ * still running on the instance keep their worker.
153
+ */
154
+ static forModel(model: string): LocalEmbeddingBackend {
155
+ let backend = LocalEmbeddingBackend.byModel.get(model);
156
+ if (!backend) {
157
+ backend = new LocalEmbeddingBackend(model);
158
+ LocalEmbeddingBackend.byModel.set(model, backend);
159
+ }
160
+ if (!backend.processExiting) {
161
+ backend.disposeRequested = false;
162
+ }
163
+ return backend;
164
+ }
165
+
166
+ /** {@link shutdown} every backend, then reap any worker still parented here. */
167
+ static async shutdownAll(): Promise<void> {
168
+ await Promise.all(
169
+ [...LocalEmbeddingBackend.byModel.values()].map(async (backend) => {
170
+ try {
171
+ await backend.shutdown();
172
+ await backend.sweepOwnedWorkers();
173
+ } catch (err) {
174
+ log.warn(
175
+ { err, model: backend.model },
176
+ "Failed to shut down local embedding backend",
177
+ );
178
+ }
179
+ }),
180
+ );
181
+ }
182
+
183
+ /** {@link terminateNow} every backend. */
184
+ static terminateAllNow(): void {
185
+ for (const backend of LocalEmbeddingBackend.byModel.values()) {
186
+ try {
187
+ backend.terminateNow();
188
+ } catch (err) {
189
+ log.warn(
190
+ { err, model: backend.model },
191
+ "Failed to terminate local embedding worker",
192
+ );
193
+ }
194
+ }
195
+ }
196
+
197
+ private constructor(model: string) {
134
198
  this.model = model;
135
199
  }
136
200
 
@@ -193,19 +257,9 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
193
257
  }
194
258
  this.pendingRequests.set(id, { resolve });
195
259
 
196
- // Writing to a worker that has already exited raises EPIPE. That must
197
- // surface as an ordinary embed failure the caller can fall back from:
198
- // an escaping EPIPE reaches the daemon's `unhandledRejection` handler,
199
- // which tears down the whole process (JARVIS-1125).
200
- //
201
- // The guard covers `write` as well as `flush`: both are synchronous on
202
- // Bun's FileSink, and `write` is the call that raises the broken pipe.
203
- try {
204
- proc.stdin.write(JSON.stringify({ id, texts }) + "\n");
205
- proc.stdin.flush();
206
- } catch (err) {
207
- this.failPendingRequest(id, err);
208
- }
260
+ writeWorkerLine(proc.stdin, JSON.stringify({ id, texts }), (err) =>
261
+ this.failPendingRequest(id, err),
262
+ );
209
263
  });
210
264
  }
211
265
 
@@ -929,6 +983,7 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
929
983
  * the OS confirms the worker is gone.
930
984
  */
931
985
  async shutdown(): Promise<void> {
986
+ this.processExiting = true;
932
987
  this.disposeRequested = true;
933
988
 
934
989
  // An initialization already in flight has not necessarily assigned
@@ -113,25 +113,6 @@ export interface EmbeddingBackend {
113
113
  options?: EmbeddingRequestOptions,
114
114
  ): Promise<number[][]>;
115
115
  dispose?(): void;
116
- /**
117
- * Deterministic teardown for daemon shutdown: release any OS resources the
118
- * backend owns (e.g. a worker subprocess) and resolve only once they are
119
- * confirmed gone. Unlike {@link dispose}, which may defer while embeds are
120
- * in flight, this must not leave a child process behind.
121
- */
122
- shutdown?(): Promise<void>;
123
- /**
124
- * Release owned OS resources synchronously, for a process that must exit this
125
- * tick. Unlike {@link shutdown} it cannot wait for confirmation, so it uses
126
- * the uncatchable signal rather than the graceful one.
127
- */
128
- terminateNow?(): void;
129
- /**
130
- * Reap any owned child still attributable to this process, without needing a
131
- * handle for it. Backstop for a teardown that could not resolve its handle in
132
- * time.
133
- */
134
- sweepOwnedWorkers?(): Promise<void>;
135
116
  }
136
117
 
137
118
  /**
@@ -1,4 +1,4 @@
1
- import { getUserSelectableProfilesForProvider } from "../config/default-profile-catalog.js";
1
+ import { getConversationProfilesForProvider } from "../config/default-profile-catalog.js";
2
2
  import { getConfig } from "../config/loader.js";
3
3
  import { isDispatchableProfile } from "../config/profile-dispatchability.js";
4
4
  import { orderProfileKeys } from "../config/profile-order.js";
@@ -7,7 +7,9 @@ import type { ModelProfileInfo } from "./types.js";
7
7
  /**
8
8
  * List the workspace inference profiles a plugin can route to, in the order a
9
9
  * user-facing model picker presents them (`llm.profileOrder` first, then the
10
- * rest alphabetically). Managed backup routes are internal and omitted.
10
+ * rest alphabetically). Managed backup routes and the managed Jev profile
11
+ * are omitted with the rest of the conversation view: a plugin routes
12
+ * conversation turns, and a decision model cannot answer them.
11
13
  * Metadata-only entries without a provider, model, or mix are not routing
12
14
  * targets, so plugins never see them. Disabled profiles are included and flagged via
13
15
  * {@link ModelProfileInfo.isDisabled}; weighted "mix" profiles are included and
@@ -20,7 +22,7 @@ import type { ModelProfileInfo } from "./types.js";
20
22
  export function getModelProfiles(): ModelProfileInfo[] {
21
23
  const { llm } = getConfig();
22
24
  const { activeProfile } = llm;
23
- const profiles = getUserSelectableProfilesForProvider(
25
+ const profiles = getConversationProfilesForProvider(
24
26
  llm.profiles,
25
27
  llm.defaultProvider ?? null,
26
28
  );
@@ -238,6 +238,7 @@ describe("memory-retrospective skill card", () => {
238
238
  metadata: { kind: "skill-authored-card", automated: true },
239
239
  skipIndexing: true,
240
240
  clientMessageId: "skill-card-run-conv-1",
241
+ skipResurface: true,
241
242
  });
242
243
  // Disk view sync mirrors the wake-persist path, then clients are told to
243
244
  // refetch the message list.
@@ -423,6 +424,7 @@ describe("memory-retrospective skill card", () => {
423
424
  metadata: { kind: "skill-authored-card", automated: true },
424
425
  skipIndexing: true,
425
426
  clientMessageId: "skill-card-run-conv-1",
427
+ skipResurface: true,
426
428
  });
427
429
  expect(syncedToDisk).toHaveLength(1);
428
430
  expect(publishedConversationIds).toEqual(["src-conv-9"]);
@@ -321,6 +321,11 @@ async function insertOrDeferSkillCard(
321
321
  metadata: { kind: SKILL_CARD_MESSAGE_KIND, automated: true },
322
322
  skipIndexing: true,
323
323
  clientMessageId: surfaceId,
324
+ // The card is what the pass learned about this conversation, not new
325
+ // activity in it, and a pass runs once the conversation has gone idle.
326
+ // That is exactly when the user has just marked it done, so a card that
327
+ // resurfaced would bounce fresh done chats back into the sidebar.
328
+ skipResurface: true,
324
329
  },
325
330
  );
326
331
  if (persisted.deduplicated) {
@@ -16,6 +16,10 @@ import {
16
16
  } from "../content-block-size.js";
17
17
  import { fileBlockToProviderText } from "../file-block-text.js";
18
18
  import { unsignedThoughtSignatureFallback } from "../gemini-thought-signature.js";
19
+ import {
20
+ isMalformedToolCallFinishReason,
21
+ malformedToolCallError,
22
+ } from "../malformed-tool-call.js";
19
23
  import { base64Source, resolveMediaReferences } from "../media-resolve.js";
20
24
  import { PROVIDER_CATALOG } from "../model-catalog.js";
21
25
  import { recordProviderRequestDiagnostics } from "../request-diagnostics.js";
@@ -577,6 +581,18 @@ export class GeminiProvider implements Provider {
577
581
  cleanupTimeout();
578
582
  }
579
583
 
584
+ if (isMalformedToolCallFinishReason(finishReason)) {
585
+ log.warn(
586
+ {
587
+ model: responseModel,
588
+ finishReason,
589
+ streamedTextLength: fullText.length,
590
+ },
591
+ "Gemini response ended on a malformed tool call",
592
+ );
593
+ throw malformedToolCallError("gemini", finishReason);
594
+ }
595
+
580
596
  // Build content blocks
581
597
  const content: ContentBlock[] = [];
582
598
  if (fullText) {
@@ -649,7 +665,7 @@ export class GeminiProvider implements Provider {
649
665
  error.status,
650
666
  // Skip reason on caller-abort: abortReason already carries the intent
651
667
  // and short-circuits classification/retry (mirrors the Anthropic client).
652
- abortReason
668
+ abortReason
653
669
  ? { abortReason, rawRequest: inspectableRequest }
654
670
  : {
655
671
  reason: deriveGeminiReason(error),
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Upstream finish reasons that mean the model tried to call a tool but the
3
+ * call did not parse.
4
+ *
5
+ * Gemini reports these as `MALFORMED_FUNCTION_CALL` (the call was not valid)
6
+ * and `UNEXPECTED_TOOL_CALL` (a call the request did not allow). The native
7
+ * Gemini API returns them as `candidate.finishReason`; OpenRouter normalizes
8
+ * them away in `finish_reason` and passes the raw value through in
9
+ * `native_finish_reason`. Such responses often carry the tail of the broken
10
+ * call as plain assistant text, so they must never be taken as a final answer.
11
+ * Providers throw a status-less `ProviderError` carrying
12
+ * {@link MALFORMED_TOOL_CALL_MESSAGE}, which the retry layer (`retry.ts`)
13
+ * resends once with a corrective note.
14
+ */
15
+
16
+ import { ProviderError } from "../util/errors.js";
17
+
18
+ /** Message fragment shared by the provider throw sites and the retry classifier. */
19
+ export const MALFORMED_TOOL_CALL_MESSAGE =
20
+ "Model emitted a malformed tool call";
21
+
22
+ const MALFORMED_TOOL_CALL_FINISH_REASONS = new Set([
23
+ "MALFORMED_FUNCTION_CALL",
24
+ "UNEXPECTED_TOOL_CALL",
25
+ ]);
26
+
27
+ /**
28
+ * Whether an upstream finish reason denotes a malformed tool call.
29
+ * Case- and whitespace-insensitive; a `null`/`undefined`/empty reason is false.
30
+ */
31
+ export function isMalformedToolCallFinishReason(
32
+ finishReason: string | null | undefined,
33
+ ): boolean {
34
+ if (!finishReason) {
35
+ return false;
36
+ }
37
+ return MALFORMED_TOOL_CALL_FINISH_REASONS.has(
38
+ finishReason.trim().toUpperCase(),
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Status-less provider error for a response that ended on a malformed tool
44
+ * call. The absent status marks it as a stream-content failure, which the
45
+ * retry layer classifies by message.
46
+ */
47
+ export function malformedToolCallError(
48
+ provider: string,
49
+ finishReason: string,
50
+ ): ProviderError {
51
+ return new ProviderError(
52
+ `${MALFORMED_TOOL_CALL_MESSAGE} (finish reason: ${finishReason})`,
53
+ provider,
54
+ );
55
+ }
@@ -1,7 +1,9 @@
1
1
  import { PLATFORM_PROVIDER_META } from "./platform-proxy/constants.js";
2
2
 
3
3
  export type LongContextMode =
4
- "native-model" | "provider-request-option" | "unsupported";
4
+ | "native-model"
5
+ | "provider-request-option"
6
+ | "unsupported";
5
7
 
6
8
  export interface CatalogModelPricingTier {
7
9
  /**
@@ -2602,6 +2604,16 @@ export const PROVIDER_CATALOG: ProviderCatalogEntry[] =
2602
2604
  supportsPlatformAuth: PLATFORM_PROVIDER_META[entry.id]?.managed === true,
2603
2605
  }));
2604
2606
 
2607
+ /**
2608
+ * Providers that are routing identities rather than catalog owners. Mirrors
2609
+ * `ROUTING_IDENTITY_PROVIDERS` in `inference/auth.ts`, which cannot be
2610
+ * imported here without a cycle.
2611
+ */
2612
+ const ROUTING_IDENTITY_PROVIDER_IDS: ReadonlySet<string> = new Set([
2613
+ "vellum",
2614
+ "chatgpt",
2615
+ ]);
2616
+
2605
2617
  /**
2606
2618
  * Whether a catalog model produces free-form chat text. Unlisted providers
2607
2619
  * and model ids default to true so custom endpoints and unknown snapshots
@@ -2614,9 +2626,16 @@ export function catalogModelSupportsText(
2614
2626
  if (typeof provider !== "string" || typeof modelId !== "string") {
2615
2627
  return true;
2616
2628
  }
2617
- const model = PROVIDER_CATALOG.find((p) => p.id === provider)?.models.find(
2618
- (m) => m.id === modelId,
2619
- );
2629
+ // A routing identity ("vellum", "chatgpt") owns no catalog models of the
2630
+ // upstream it dispatches to, so judge the model by its catalog owner: a
2631
+ // managed Jev profile is `vellum` + `jev-latest`, and `jev-latest` opts out
2632
+ // of text under `typesafe`.
2633
+ const catalogProvider = ROUTING_IDENTITY_PROVIDER_IDS.has(provider)
2634
+ ? (getCatalogProviderForModel(modelId) ?? provider)
2635
+ : provider;
2636
+ const model = PROVIDER_CATALOG.find(
2637
+ (p) => p.id === catalogProvider,
2638
+ )?.models.find((m) => m.id === modelId);
2620
2639
  return model?.supportsText !== false;
2621
2640
  }
2622
2641
 
@@ -0,0 +1,105 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { ProviderError } from "../../../util/errors.js";
4
+ import { MALFORMED_TOOL_CALL_MESSAGE } from "../../malformed-tool-call.js";
5
+ import { OpenAIChatCompletionsProvider } from "../chat-completions-provider.js";
6
+
7
+ type MockChunk = {
8
+ choices: Array<{
9
+ delta: { content?: string | null };
10
+ finish_reason?: string | null;
11
+ native_finish_reason?: string | null;
12
+ }>;
13
+ usage?: { prompt_tokens: number; completion_tokens: number };
14
+ };
15
+
16
+ const MESSAGES = [
17
+ {
18
+ role: "user" as const,
19
+ content: [{ type: "text" as const, text: "upload the cover image" }],
20
+ },
21
+ ];
22
+
23
+ function stubProvider(chunks: MockChunk[]): OpenAIChatCompletionsProvider {
24
+ const provider = new OpenAIChatCompletionsProvider(
25
+ "test-key",
26
+ "google/gemini-3.8-flash",
27
+ );
28
+ (provider as unknown as { client: unknown }).client = {
29
+ baseURL: "https://openrouter.example.com/api/v1",
30
+ chat: {
31
+ completions: {
32
+ create: async () => ({
33
+ async *[Symbol.asyncIterator]() {
34
+ for (const c of chunks) {
35
+ yield c;
36
+ }
37
+ },
38
+ }),
39
+ },
40
+ },
41
+ };
42
+ return provider;
43
+ }
44
+
45
+ function finishChunks(
46
+ text: string,
47
+ finishReason: string,
48
+ nativeFinishReason: string,
49
+ ): MockChunk[] {
50
+ return [
51
+ { choices: [{ delta: { content: text } }] },
52
+ {
53
+ choices: [
54
+ {
55
+ delta: {},
56
+ finish_reason: finishReason,
57
+ native_finish_reason: nativeFinishReason,
58
+ },
59
+ ],
60
+ usage: { prompt_tokens: 1, completion_tokens: 2 },
61
+ },
62
+ ];
63
+ }
64
+
65
+ describe("OpenAIChatCompletionsProvider malformed tool-call finish", () => {
66
+ test("throws a status-less malformed tool-call error on native MALFORMED_FUNCTION_CALL", async () => {
67
+ const provider = stubProvider(
68
+ finishChunks(
69
+ ",featuredImage:{_type:image},patch:true}]})",
70
+ "stop",
71
+ "MALFORMED_FUNCTION_CALL",
72
+ ),
73
+ );
74
+
75
+ const error = await provider.sendMessage(MESSAGES).then(
76
+ () => undefined,
77
+ (e: unknown) => e,
78
+ );
79
+
80
+ expect(error).toBeInstanceOf(ProviderError);
81
+ const providerError = error as ProviderError;
82
+ expect(providerError.statusCode).toBeUndefined();
83
+ expect(providerError.message).toContain(MALFORMED_TOOL_CALL_MESSAGE);
84
+ expect(providerError.message).toContain("MALFORMED_FUNCTION_CALL");
85
+ });
86
+
87
+ test("throws on native UNEXPECTED_TOOL_CALL", async () => {
88
+ const provider = stubProvider(
89
+ finishChunks("{}", "error", "UNEXPECTED_TOOL_CALL"),
90
+ );
91
+
92
+ await expect(provider.sendMessage(MESSAGES)).rejects.toThrow(
93
+ MALFORMED_TOOL_CALL_MESSAGE,
94
+ );
95
+ });
96
+
97
+ test("returns the response normally on native STOP", async () => {
98
+ const provider = stubProvider(finishChunks("All done.", "stop", "STOP"));
99
+
100
+ const response = await provider.sendMessage(MESSAGES);
101
+
102
+ expect(response.stopReason).toBe("stop");
103
+ expect(response.content).toEqual([{ type: "text", text: "All done." }]);
104
+ });
105
+ });
@@ -12,6 +12,10 @@ import { partialTagSuffix as sharedPartialTagSuffix } from "../../util/think-tag
12
12
  import { clampProviderString } from "../content-block-size.js";
13
13
  import { fileBlockToProviderText } from "../file-block-text.js";
14
14
  import { requestSupportsInlineAudio } from "../inline-audio-support.js";
15
+ import {
16
+ isMalformedToolCallFinishReason,
17
+ malformedToolCallError,
18
+ } from "../malformed-tool-call.js";
15
19
  import {
16
20
  base64Source,
17
21
  mediaSourceByteLength,
@@ -1150,6 +1154,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
1150
1154
  >();
1151
1155
  const toolProgress = createToolProgressEmitter(onEvent);
1152
1156
  let finishReason = "unknown";
1157
+ let upstreamFinishReason: string | undefined;
1153
1158
  let responseModel = modelOverride ?? this.model;
1154
1159
  let promptTokens = 0;
1155
1160
  let completionTokens = 0;
@@ -1318,6 +1323,14 @@ export class OpenAIChatCompletionsProvider implements Provider {
1318
1323
  if (choice.finish_reason) {
1319
1324
  finishReason = choice.finish_reason;
1320
1325
  }
1326
+ // OpenRouter normalizes upstream finish reasons and carries the
1327
+ // raw value in `native_finish_reason`.
1328
+ const nativeFinishReason = (
1329
+ choice as { native_finish_reason?: string | null }
1330
+ ).native_finish_reason;
1331
+ if (nativeFinishReason) {
1332
+ upstreamFinishReason = nativeFinishReason;
1333
+ }
1321
1334
  }
1322
1335
 
1323
1336
  if (chunk.usage) {
@@ -1353,6 +1366,23 @@ export class OpenAIChatCompletionsProvider implements Provider {
1353
1366
  cleanupTimeout();
1354
1367
  }
1355
1368
 
1369
+ const malformedFinishReason = [upstreamFinishReason, finishReason].find(
1370
+ isMalformedToolCallFinishReason,
1371
+ );
1372
+ if (malformedFinishReason) {
1373
+ log.warn(
1374
+ {
1375
+ provider: this.name,
1376
+ model: responseModel,
1377
+ finishReason,
1378
+ upstreamFinishReason,
1379
+ streamedTextLength: contentText.length,
1380
+ },
1381
+ "Response ended on a malformed tool call",
1382
+ );
1383
+ throw malformedToolCallError(this.name, malformedFinishReason);
1384
+ }
1385
+
1356
1386
  if (this.parseThinkTags && pendingContent) {
1357
1387
  flushPendingContent(true);
1358
1388
  }
@@ -56,6 +56,11 @@ export const PLATFORM_PROVIDER_META: Record<string, ManagedProviderMeta> = {
56
56
  managed: true,
57
57
  proxyPath: "/v1/runtime-proxy/vellum",
58
58
  },
59
+ typesafe: {
60
+ name: "typesafe",
61
+ managed: true,
62
+ proxyPath: "/v1/runtime-proxy/typesafe",
63
+ },
59
64
  poolside: {
60
65
  name: "poolside",
61
66
  managed: false,