@vellumai/assistant 0.12.3-staging.2 → 0.12.4-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 (69) 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 +6 -2
  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__/conversation-resurface-done.test.ts +289 -0
  9. package/src/__tests__/gemini-provider.test.ts +31 -0
  10. package/src/__tests__/provider-error-scenarios.test.ts +46 -0
  11. package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
  12. package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
  13. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  14. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
  15. package/src/__tests__/send-endpoint-busy.test.ts +4 -2
  16. package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
  17. package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
  18. package/src/cli/lib/bundled-marketplace.json +4 -4
  19. package/src/config/feature-flag-registry.json +8 -8
  20. package/src/config/sidebar-done-gate.ts +27 -0
  21. package/src/daemon/conversation-turn-finalize.ts +12 -0
  22. package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
  23. package/src/daemon/handlers/config-channels.ts +3 -1
  24. package/src/daemon/handlers/conversation-history.ts +11 -1
  25. package/src/daemon/handlers/conversations.ts +118 -10
  26. package/src/desktop/desktop-browser-endpoint.ts +1 -0
  27. package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
  28. package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
  29. package/src/permissions/question-prompter.ts +14 -4
  30. package/src/persistence/conversation-crud.ts +114 -3
  31. package/src/persistence/conversation-queries.ts +48 -3
  32. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +157 -32
  33. package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
  34. package/src/persistence/embeddings/embedding-backend.ts +23 -41
  35. package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
  36. package/src/persistence/embeddings/embedding-gemini.ts +3 -1
  37. package/src/persistence/embeddings/embedding-local.ts +72 -17
  38. package/src/persistence/embeddings/embedding-types.ts +0 -19
  39. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
  40. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
  41. package/src/providers/gemini/client.ts +17 -1
  42. package/src/providers/malformed-tool-call.ts +55 -0
  43. package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
  44. package/src/providers/openai/chat-completions-provider.ts +30 -0
  45. package/src/providers/platform-proxy/constants.ts +5 -0
  46. package/src/providers/retry.ts +68 -36
  47. package/src/providers/vellum-model-routing.test.ts +1 -0
  48. package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
  49. package/src/runtime/__tests__/question-resolution.test.ts +32 -0
  50. package/src/runtime/agent-wake.ts +9 -2
  51. package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
  52. package/src/runtime/auth/middleware.ts +15 -11
  53. package/src/runtime/middleware/auth.ts +7 -1
  54. package/src/runtime/pending-interactions.ts +8 -8
  55. package/src/runtime/question-resolution.ts +54 -8
  56. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
  57. package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
  58. package/src/runtime/routes/conversation-list-routes.ts +16 -14
  59. package/src/runtime/routes/conversation-query-routes.ts +4 -0
  60. package/src/runtime/routes/global-search-routes.ts +22 -7
  61. package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
  62. package/src/runtime/sync/resource-sync-events.ts +11 -0
  63. package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
  64. package/src/runtime/sync/worker-daemon-notify.ts +44 -0
  65. package/src/runtime/verification-outbound-actions.ts +2 -2
  66. package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
  67. package/src/tools/ask-question/ask-question-tool.ts +12 -1
  68. package/src/util/worker-pipe.test.ts +100 -0
  69. package/src/util/worker-pipe.ts +44 -0
@@ -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
  /**
@@ -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
+ }
@@ -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,
@@ -36,6 +36,7 @@ import {
36
36
  tryAcquireRecoveryProbe,
37
37
  } from "./fallback-breaker.js";
38
38
  import { resolveLogitBiasPreset } from "./inference/logit-bias.js";
39
+ import { MALFORMED_TOOL_CALL_MESSAGE } from "./malformed-tool-call.js";
39
40
  import {
40
41
  isAdaptiveThinkingOnlyModel,
41
42
  isAdaptiveThinkingUnsupportedModel,
@@ -165,18 +166,22 @@ const RETRYABLE_STREAM_PATTERNS = [
165
166
  // Anthropic client salvages most of these into a `_raw`-wrapped tool call
166
167
  // before they surface (see anthropic/stream-content-shadow.ts); ones that
167
168
  // still reach here retry with a corrective note
168
- // (`withUnparseableToolArgsHint`) because the malformation can be
169
- // conditioned on the request context — a byte-identical resend can
170
- // reproduce it indefinitely.
169
+ // (`correctiveRetryHint`) because the malformation can be conditioned on
170
+ // the request context: a byte-identical resend can reproduce it
171
+ // indefinitely.
171
172
  UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE,
173
+ // The response ended on a tool call the model emitted but that did not
174
+ // parse (Gemini `MALFORMED_FUNCTION_CALL` / `UNEXPECTED_TOOL_CALL`). Its
175
+ // text is the broken call's tail, so it retries with a corrective note too.
176
+ MALFORMED_TOOL_CALL_MESSAGE,
172
177
  ];
173
178
 
174
179
  /**
175
- * One-shot note appended to the retried request after a tool-argument JSON
176
- * parse failure. Appended as a trailing text block on the latest user
177
- * message: the request tail sits after every prompt-cache anchor, so the
178
- * hint costs no cache reuse (a system-prompt edit would invalidate the whole
179
- * cached prefix).
180
+ * One-shot notes appended to a retried request after the model's output was
181
+ * malformed in a way a byte-identical resend can reproduce. Appended as a
182
+ * trailing text block on the latest user message: the request tail sits
183
+ * after every prompt-cache anchor, so the hint costs no cache reuse (a
184
+ * system-prompt edit would invalidate the whole cached prefix).
180
185
  */
181
186
  const UNPARSEABLE_TOOL_ARGS_RETRY_HINT =
182
187
  "[assistant runtime] The previous attempt at this response was discarded: " +
@@ -185,23 +190,51 @@ const UNPARSEABLE_TOOL_ARGS_RETRY_HINT =
185
190
  "every string value double-quoted, including values that begin with '[' " +
186
191
  "or '{'.";
187
192
 
188
- function isUnparseableToolArgsError(error: unknown): boolean {
193
+ const MALFORMED_TOOL_CALL_RETRY_HINT =
194
+ "[assistant runtime] The previous attempt at this response was discarded: " +
195
+ "it ended on a tool call that did not parse. Respond again. To call a " +
196
+ "tool, emit one complete, well-formed tool call through the tool-calling " +
197
+ "interface, never as text.";
198
+
199
+ const CORRECTIVE_RETRY_HINTS: ReadonlyArray<{
200
+ pattern: string;
201
+ hint: string;
202
+ }> = [
203
+ {
204
+ pattern: UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE,
205
+ hint: UNPARSEABLE_TOOL_ARGS_RETRY_HINT,
206
+ },
207
+ {
208
+ pattern: MALFORMED_TOOL_CALL_MESSAGE,
209
+ hint: MALFORMED_TOOL_CALL_RETRY_HINT,
210
+ },
211
+ ];
212
+
213
+ /**
214
+ * The corrective note a retry of `error` carries, or `undefined` when the
215
+ * error is not a request-conditioned output malformation. Only status-less
216
+ * provider errors qualify: those come from content the upstream streamed
217
+ * after accepting the request.
218
+ */
219
+ function correctiveRetryHint(error: unknown): string | undefined {
189
220
  if (!(error instanceof ProviderError)) {
190
- return false;
221
+ return undefined;
191
222
  }
192
223
  if (error.statusCode !== undefined) {
193
- return false;
224
+ return undefined;
194
225
  }
195
- return error.message.includes(UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE);
226
+ return CORRECTIVE_RETRY_HINTS.find(({ pattern }) =>
227
+ error.message.includes(pattern),
228
+ )?.hint;
196
229
  }
197
230
 
198
231
  /**
199
- * Copy of `messages` with the corrective note appended to the latest user
200
- * message. When the request doesn't end on a user message (assistant
201
- * prefill), returns `messages` unchanged — appending anything there would
202
- * change prefill semantics.
232
+ * Copy of `messages` with `hint` appended to the latest user message. When
233
+ * the request doesn't end on a user message (assistant prefill), returns
234
+ * `messages` unchanged, since appending anything there would change prefill
235
+ * semantics.
203
236
  */
204
- function withUnparseableToolArgsHint(messages: Message[]): Message[] {
237
+ function withRetryHint(messages: Message[], hint: string): Message[] {
205
238
  const last = messages[messages.length - 1];
206
239
  if (last === undefined || last.role !== "user") {
207
240
  return messages;
@@ -210,10 +243,7 @@ function withUnparseableToolArgsHint(messages: Message[]): Message[] {
210
243
  ...messages.slice(0, -1),
211
244
  {
212
245
  ...last,
213
- content: [
214
- ...last.content,
215
- { type: "text", text: UNPARSEABLE_TOOL_ARGS_RETRY_HINT },
216
- ],
246
+ content: [...last.content, { type: "text", text: hint }],
217
247
  },
218
248
  ];
219
249
  }
@@ -1393,12 +1423,10 @@ export class RetryProvider implements Provider {
1393
1423
  // outage the route had nothing to do with. Skipped when the hint
1394
1424
  // has nowhere to go (an assistant prefill tail), since an
1395
1425
  // unchanged resend would only cost another round trip.
1396
- if (
1397
- !correctiveResendAttempted &&
1398
- isUnparseableToolArgsError(error)
1399
- ) {
1426
+ const hint = correctiveRetryHint(error);
1427
+ if (!correctiveResendAttempted && hint !== undefined) {
1400
1428
  correctiveResendAttempted = true;
1401
- const repaired = withUnparseableToolArgsHint(messages);
1429
+ const repaired = withRetryHint(messages, hint);
1402
1430
  if (repaired !== messages) {
1403
1431
  messagesForAttempt = repaired;
1404
1432
  continue;
@@ -1522,12 +1550,13 @@ export class RetryProvider implements Provider {
1522
1550
  }
1523
1551
 
1524
1552
  if (retryAttempt < DEFAULT_MAX_RETRIES && isRetryableError(error)) {
1525
- // Malformed tool-argument JSON is conditioned on the request, so
1526
- // resend with the corrective note. Built from the original
1527
- // `messages` each time — the note appears exactly once no matter
1528
- // how many attempts fail this way.
1529
- if (isUnparseableToolArgsError(error)) {
1530
- messagesForAttempt = withUnparseableToolArgsHint(messages);
1553
+ // A malformed tool call is conditioned on the request, so resend
1554
+ // with the corrective note. Built from the original `messages`
1555
+ // each time, so the note appears exactly once no matter how many
1556
+ // attempts fail this way.
1557
+ const hint = correctiveRetryHint(error);
1558
+ if (hint !== undefined) {
1559
+ messagesForAttempt = withRetryHint(messages, hint);
1531
1560
  }
1532
1561
  // Prefer server-provided Retry-After; fall back to exponential backoff.
1533
1562
  const { delay, retryAfterHeader } = retryPlan(error, retryAttempt);
@@ -1587,9 +1616,11 @@ export class RetryProvider implements Provider {
1587
1616
  ) {
1588
1617
  fallbackAttempted = true;
1589
1618
  // What the failure indicts: the whole upstream for an outage, only
1590
- // this model for a retirement or rename.
1619
+ // this model for a retirement or rename. A malformed output that
1620
+ // takes a corrective hint indicts neither: it is conditioned on this
1621
+ // request, so the backup may serve it without diverting the route.
1591
1622
  const failedRoute =
1592
- breakerRoute === null
1623
+ breakerRoute === null || correctiveRetryHint(error) !== undefined
1593
1624
  ? null
1594
1625
  : failureBreakerRoute(breakerRoute, error);
1595
1626
  let failureObservation: BreakerObservation | undefined;
@@ -1730,8 +1761,9 @@ export class RetryProvider implements Provider {
1730
1761
  }
1731
1762
  // Built from the original `messages` each time, so the corrective note
1732
1763
  // appears exactly once however many attempts fail this way.
1733
- if (isUnparseableToolArgsError(error)) {
1734
- messagesForAttempt = withUnparseableToolArgsHint(messages);
1764
+ const hint = correctiveRetryHint(error);
1765
+ if (hint !== undefined) {
1766
+ messagesForAttempt = withRetryHint(messages, hint);
1735
1767
  }
1736
1768
  const { delay, retryAfterHeader } = retryPlan(error, attempt);
1737
1769
  log.warn(
@@ -73,6 +73,7 @@ describe("vellum-model-routing", () => {
73
73
  "gemini",
74
74
  "openai",
75
75
  "together",
76
+ "typesafe",
76
77
  "vellum",
77
78
  ]);
78
79
  });