@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.
- package/Dockerfile +7 -0
- package/README.md +12 -12
- package/docker/mousepad/90-vellum.gschema.override +4 -0
- package/openapi.yaml +6 -2
- package/package.json +1 -1
- package/src/__tests__/approval-routes-http.test.ts +4 -2
- package/src/__tests__/call-routes-http.test.ts +4 -2
- package/src/__tests__/conversation-resurface-done.test.ts +289 -0
- package/src/__tests__/gemini-provider.test.ts +31 -0
- package/src/__tests__/provider-error-scenarios.test.ts +46 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
- package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
- package/src/__tests__/send-endpoint-busy.test.ts +4 -2
- package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
- package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
- package/src/cli/lib/bundled-marketplace.json +4 -4
- package/src/config/feature-flag-registry.json +8 -8
- package/src/config/sidebar-done-gate.ts +27 -0
- package/src/daemon/conversation-turn-finalize.ts +12 -0
- package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
- package/src/daemon/handlers/config-channels.ts +3 -1
- package/src/daemon/handlers/conversation-history.ts +11 -1
- package/src/daemon/handlers/conversations.ts +118 -10
- package/src/desktop/desktop-browser-endpoint.ts +1 -0
- package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
- package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
- package/src/permissions/question-prompter.ts +14 -4
- package/src/persistence/conversation-crud.ts +114 -3
- package/src/persistence/conversation-queries.ts +48 -3
- package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +157 -32
- package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
- package/src/persistence/embeddings/embedding-backend.ts +23 -41
- package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
- package/src/persistence/embeddings/embedding-gemini.ts +3 -1
- package/src/persistence/embeddings/embedding-local.ts +72 -17
- package/src/persistence/embeddings/embedding-types.ts +0 -19
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
- package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
- package/src/providers/gemini/client.ts +17 -1
- package/src/providers/malformed-tool-call.ts +55 -0
- package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
- package/src/providers/openai/chat-completions-provider.ts +30 -0
- package/src/providers/platform-proxy/constants.ts +5 -0
- package/src/providers/retry.ts +68 -36
- package/src/providers/vellum-model-routing.test.ts +1 -0
- package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
- package/src/runtime/__tests__/question-resolution.test.ts +32 -0
- package/src/runtime/agent-wake.ts +9 -2
- package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
- package/src/runtime/auth/middleware.ts +15 -11
- package/src/runtime/middleware/auth.ts +7 -1
- package/src/runtime/pending-interactions.ts +8 -8
- package/src/runtime/question-resolution.ts +54 -8
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
- package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
- package/src/runtime/routes/conversation-list-routes.ts +16 -14
- package/src/runtime/routes/conversation-query-routes.ts +4 -0
- package/src/runtime/routes/global-search-routes.ts +22 -7
- package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
- package/src/runtime/sync/resource-sync-events.ts +11 -0
- package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
- package/src/runtime/sync/worker-daemon-notify.ts +44 -0
- package/src/runtime/verification-outbound-actions.ts +2 -2
- package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
- package/src/tools/ask-question/ask-question-tool.ts +12 -1
- package/src/util/worker-pipe.test.ts +100 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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,
|
package/src/providers/retry.ts
CHANGED
|
@@ -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
|
-
// (`
|
|
169
|
-
//
|
|
170
|
-
//
|
|
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
|
|
176
|
-
*
|
|
177
|
-
* message: the request tail sits
|
|
178
|
-
* hint costs no cache reuse (a
|
|
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
|
-
|
|
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
|
|
221
|
+
return undefined;
|
|
191
222
|
}
|
|
192
223
|
if (error.statusCode !== undefined) {
|
|
193
|
-
return
|
|
224
|
+
return undefined;
|
|
194
225
|
}
|
|
195
|
-
return
|
|
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
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
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
|
|
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
|
-
|
|
1397
|
-
|
|
1398
|
-
isUnparseableToolArgsError(error)
|
|
1399
|
-
) {
|
|
1426
|
+
const hint = correctiveRetryHint(error);
|
|
1427
|
+
if (!correctiveResendAttempted && hint !== undefined) {
|
|
1400
1428
|
correctiveResendAttempted = true;
|
|
1401
|
-
const repaired =
|
|
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
|
-
//
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
1528
|
-
//
|
|
1529
|
-
|
|
1530
|
-
|
|
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
|
-
|
|
1734
|
-
|
|
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(
|