@kuralle-syrinx/aisdk 4.2.0 → 4.4.0
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/package.json +3 -3
- package/src/from-ai-sdk.ts +40 -1
- package/src/index.ts +144 -1
- package/src/from-ai-sdk.test.ts +0 -276
- package/src/heard-prefix-commit.test.ts +0 -291
- package/src/index.test.ts +0 -989
- package/src/speculative-on-ledger.test.ts +0 -211
- package/src/speculative-post-promotion.test.ts +0 -86
- package/tsconfig.json +0 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/aisdk",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Vercel AI SDK bridge for Syrinx — drive any ai@6 model or agent as the voice pipeline's reasoner, with opt-in speculative generation",
|
|
6
6
|
"keywords": [
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
"@ai-sdk/openai": "^3.0.67",
|
|
29
29
|
"ai": "^6.0.0",
|
|
30
30
|
"zod": "^4.1.8",
|
|
31
|
-
"@kuralle-syrinx/core": "4.
|
|
31
|
+
"@kuralle-syrinx/core": "4.4.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"typescript": "^5.7.0",
|
|
35
|
-
"vitest": "^2.
|
|
35
|
+
"vitest": "^3.2.6"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"typecheck": "tsc --noEmit",
|
package/src/from-ai-sdk.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import {
|
|
9
9
|
streamText,
|
|
10
10
|
type FinishReason,
|
|
11
|
+
type LanguageModelUsage,
|
|
11
12
|
type ModelMessage,
|
|
12
13
|
type TextStreamPart,
|
|
13
14
|
type ToolChoice,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
type Reasoner,
|
|
20
21
|
type ReasonerMessage,
|
|
21
22
|
type ReasonerTurn,
|
|
23
|
+
type ReasonerUsage,
|
|
22
24
|
type ReasoningPart,
|
|
23
25
|
} from "@kuralle-syrinx/core";
|
|
24
26
|
import type { AISDKStreamFactory } from "./index.js";
|
|
@@ -79,7 +81,7 @@ async function* streamFromStreamText(config: StreamTextConfig, turn: ReasonerTur
|
|
|
79
81
|
messages,
|
|
80
82
|
abortSignal: turn.signal,
|
|
81
83
|
});
|
|
82
|
-
yield* mapTextStreamParts(result.fullStream);
|
|
84
|
+
yield* mapTextStreamParts(result.fullStream, modelIdentity(config.model));
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
async function* streamFromFactory(factory: AISDKStreamFactory, turn: ReasonerTurn): AsyncGenerator<ReasoningPart> {
|
|
@@ -112,6 +114,7 @@ function mapMessages(messages: readonly ReasonerMessage[]): ModelMessage[] {
|
|
|
112
114
|
|
|
113
115
|
async function* mapTextStreamParts(
|
|
114
116
|
source: AsyncIterable<TextStreamPart<ToolSet>>,
|
|
117
|
+
identity: { provider?: string; model?: string } = {},
|
|
115
118
|
): AsyncGenerator<ReasoningPart> {
|
|
116
119
|
let accumulatedText = "";
|
|
117
120
|
let sawFinish = false;
|
|
@@ -150,7 +153,12 @@ async function* mapTextStreamParts(
|
|
|
150
153
|
return;
|
|
151
154
|
}
|
|
152
155
|
case "abort": {
|
|
156
|
+
// An abort is a benign cancellation (barge-in aborted the reasoner), NOT an error.
|
|
157
|
+
// Carry the web/Node standard `name === "AbortError"` so downstream `isAbortError`
|
|
158
|
+
// guards (realtime-bridge runDelegate, ReasoningBridge) swallow it instead of
|
|
159
|
+
// surfacing a fatal `bridge.error/internal_fault`.
|
|
153
160
|
const cause = new Error(part.reason ?? "AI SDK stream aborted");
|
|
161
|
+
cause.name = "AbortError";
|
|
154
162
|
yield toErrorPart(cause);
|
|
155
163
|
return;
|
|
156
164
|
}
|
|
@@ -171,6 +179,11 @@ async function* mapTextStreamParts(
|
|
|
171
179
|
type: "finish",
|
|
172
180
|
reason: mapFinishReason(part.finishReason),
|
|
173
181
|
text: accumulatedText,
|
|
182
|
+
// The AI SDK finish part carries totalUsage; forward it so the bridge can
|
|
183
|
+
// record cost. Omit entirely when the provider reported nothing.
|
|
184
|
+
...(part.totalUsage
|
|
185
|
+
? { usage: { ...identity, ...toReasonerUsage(part.totalUsage) } }
|
|
186
|
+
: {}),
|
|
174
187
|
};
|
|
175
188
|
return;
|
|
176
189
|
}
|
|
@@ -198,6 +211,32 @@ async function* mapTextStreamParts(
|
|
|
198
211
|
}
|
|
199
212
|
}
|
|
200
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Extract provider/model for cost attribution. The AI SDK model is either a bare id
|
|
216
|
+
* string (`"openai/gpt-4.1-mini"`) or a model object exposing `.provider` / `.modelId`.
|
|
217
|
+
* Without this, usage counters are tagged with empty provider/model and spend cannot be
|
|
218
|
+
* attributed to a model — the whole point of the low-cardinality tags.
|
|
219
|
+
*/
|
|
220
|
+
export function modelIdentity(model: StreamTextConfig["model"]): { provider?: string; model?: string } {
|
|
221
|
+
if (typeof model === "string") return { model };
|
|
222
|
+
const m = model as { provider?: unknown; modelId?: unknown };
|
|
223
|
+
return {
|
|
224
|
+
...(typeof m.provider === "string" ? { provider: m.provider } : {}),
|
|
225
|
+
...(typeof m.modelId === "string" ? { model: m.modelId } : {}),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Copy only the token fields the SDK actually populated (all are `number | undefined`). */
|
|
230
|
+
function toReasonerUsage(u: LanguageModelUsage): ReasonerUsage {
|
|
231
|
+
return {
|
|
232
|
+
...(u.inputTokens !== undefined ? { inputTokens: u.inputTokens } : {}),
|
|
233
|
+
...(u.outputTokens !== undefined ? { outputTokens: u.outputTokens } : {}),
|
|
234
|
+
...(u.totalTokens !== undefined ? { totalTokens: u.totalTokens } : {}),
|
|
235
|
+
...(u.cachedInputTokens !== undefined ? { cachedInputTokens: u.cachedInputTokens } : {}),
|
|
236
|
+
...(u.reasoningTokens !== undefined ? { reasoningTokens: u.reasoningTokens } : {}),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
201
240
|
function mapFinishReason(finishReason: FinishReason): "stop" | "tool" | "length" {
|
|
202
241
|
if (finishReason === "tool-calls") return "tool";
|
|
203
242
|
if (finishReason === "length") return "length";
|
package/src/index.ts
CHANGED
|
@@ -70,6 +70,10 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
70
70
|
private timeoutMs: number = 30_000;
|
|
71
71
|
private maxHistoryTurns: number = 12;
|
|
72
72
|
private history: Array<{ role: "system" | "user" | "assistant" | "tool"; content: string; toolCallId?: string }> = [];
|
|
73
|
+
private readonly transientContextMessages = new Set<{
|
|
74
|
+
role: "system";
|
|
75
|
+
content: string;
|
|
76
|
+
}>();
|
|
73
77
|
private activeGeneration: { contextId: string; controller: AbortController } | null = null;
|
|
74
78
|
// At most one speculative draft at a time; `hold` gates its side effects.
|
|
75
79
|
private speculativeDraft: {
|
|
@@ -127,6 +131,21 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
127
131
|
* unconfirmed endpoints cost extra LLM calls (Deepgram measures +50–70% at
|
|
128
132
|
* eager thresholds 0.3–0.5). Drafts never consume a suspended-run pointer —
|
|
129
133
|
* `runStore` resume stays confirmed-turn-only.
|
|
134
|
+
*
|
|
135
|
+
* **Only enable this with a confidence-gated eager endpointer (Deepgram Flux).**
|
|
136
|
+
* Promotion requires `draft.userText === eos.text` — exact equality. Flux
|
|
137
|
+
* guarantees the EndOfTurn transcript matches the preceding EagerEndOfTurn when
|
|
138
|
+
* no TurnResumed intervened, so drafts promote. `PipecatEOSPlugin` instead pushes
|
|
139
|
+
* `eos.interim` on EVERY non-empty STT interim, and each interim discards the prior
|
|
140
|
+
* draft and starts a new call, so the surviving draft is built on an interim
|
|
141
|
+
* transcript that rarely matches the final one.
|
|
142
|
+
*
|
|
143
|
+
* Measured live on smart-turn, one turn, university fixture:
|
|
144
|
+
* ON — started 13, discarded 13, promoted 0; ttfa 1724ms, llmTtft 1269ms
|
|
145
|
+
* OFF — started 0, discarded 0, promoted 0; ttfa 1302ms, llmTtft 1025ms
|
|
146
|
+
*
|
|
147
|
+
* Thirteen wasted LLM calls, zero promotions, and latency got *worse*. The lever
|
|
148
|
+
* is Flux-specific; on a per-interim endpointer it is pure cost.
|
|
130
149
|
*/
|
|
131
150
|
speculative?: boolean;
|
|
132
151
|
} = {},
|
|
@@ -136,6 +155,12 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
136
155
|
}
|
|
137
156
|
}
|
|
138
157
|
|
|
158
|
+
injectContext(text: string): void {
|
|
159
|
+
const message = { role: "system" as const, content: text };
|
|
160
|
+
this.history.push(message);
|
|
161
|
+
this.transientContextMessages.add(message);
|
|
162
|
+
}
|
|
163
|
+
|
|
139
164
|
async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
|
|
140
165
|
this.bus = bus;
|
|
141
166
|
this.iuLedger = new InMemoryIuLedger((a) => {
|
|
@@ -188,6 +213,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
188
213
|
) {
|
|
189
214
|
this.iuLedger.commit(draft.id);
|
|
190
215
|
this.speculativeDraft = null;
|
|
216
|
+
this.metric(eos.contextId, "speculative.draft_promoted");
|
|
191
217
|
for (const flush of draft.hold.buffered.splice(0)) flush();
|
|
192
218
|
return;
|
|
193
219
|
}
|
|
@@ -259,6 +285,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
259
285
|
/** Start (or restart, if a newer eager endpoint supersedes) the speculative draft. */
|
|
260
286
|
private async runDraft(userText: string, contextId: string): Promise<void> {
|
|
261
287
|
this.discardDraft();
|
|
288
|
+
this.metric(contextId, "speculative.draft_started");
|
|
262
289
|
const id = this.iuIdFor(contextId);
|
|
263
290
|
this.iuLedger.add({ id, kind: "user_turn", state: "hypothesized" });
|
|
264
291
|
const controller = new AbortController();
|
|
@@ -291,11 +318,22 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
291
318
|
this.speculativeDraft = null;
|
|
292
319
|
const committed = this.iuLedger.get(draft.id)?.state === "committed";
|
|
293
320
|
if (!committed) {
|
|
321
|
+
this.metric(draft.contextId, "speculative.draft_discarded");
|
|
294
322
|
this.iuLedger.revoke(draft.id);
|
|
295
323
|
draft.controller.abort();
|
|
296
324
|
}
|
|
297
325
|
}
|
|
298
326
|
|
|
327
|
+
private metric(contextId: string, name: string, value = "1"): void {
|
|
328
|
+
this.bus?.push(Route.Background, {
|
|
329
|
+
kind: "metric.conversation",
|
|
330
|
+
contextId,
|
|
331
|
+
timestampMs: Date.now(),
|
|
332
|
+
name,
|
|
333
|
+
value,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
299
337
|
private async processTurn(
|
|
300
338
|
userText: string,
|
|
301
339
|
contextId: string,
|
|
@@ -340,6 +378,20 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
340
378
|
let emittedDelta = false;
|
|
341
379
|
let committed = false;
|
|
342
380
|
let grounded = false;
|
|
381
|
+
let passStartedMs = 0;
|
|
382
|
+
let passTtftRecorded = false;
|
|
383
|
+
const recordPassTtft = (): void => {
|
|
384
|
+
if (passTtftRecorded) return;
|
|
385
|
+
passTtftRecorded = true;
|
|
386
|
+
const firstOutputMs = Date.now();
|
|
387
|
+
push(Route.Main, {
|
|
388
|
+
kind: "metric.conversation",
|
|
389
|
+
contextId,
|
|
390
|
+
timestampMs: firstOutputMs,
|
|
391
|
+
name: "llm.pass_ttft_ms",
|
|
392
|
+
value: String(firstOutputMs - passStartedMs),
|
|
393
|
+
});
|
|
394
|
+
};
|
|
343
395
|
|
|
344
396
|
// G2 observability: the turn's query is on its way to the reasoner (Background
|
|
345
397
|
// route, droppable — RFC bimodel-delegate-seam R4). Cascade turns have no
|
|
@@ -355,6 +407,15 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
355
407
|
try {
|
|
356
408
|
for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt += 1) {
|
|
357
409
|
grounded = false;
|
|
410
|
+
passStartedMs = Date.now();
|
|
411
|
+
passTtftRecorded = false;
|
|
412
|
+
push(Route.Main, {
|
|
413
|
+
kind: "metric.conversation",
|
|
414
|
+
contextId,
|
|
415
|
+
timestampMs: passStartedMs,
|
|
416
|
+
name: "llm.call_started",
|
|
417
|
+
value: "1",
|
|
418
|
+
});
|
|
358
419
|
try {
|
|
359
420
|
// Drafts never consume a suspended-run pointer: takePending mutates the
|
|
360
421
|
// store, and a retracted draft would silently lose the resume.
|
|
@@ -374,6 +435,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
374
435
|
case "text-delta":
|
|
375
436
|
reply += part.text;
|
|
376
437
|
emittedDelta = true;
|
|
438
|
+
recordPassTtft();
|
|
377
439
|
push(Route.Main, {
|
|
378
440
|
kind: "llm.delta",
|
|
379
441
|
contextId,
|
|
@@ -382,6 +444,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
382
444
|
});
|
|
383
445
|
break;
|
|
384
446
|
case "tool-call":
|
|
447
|
+
recordPassTtft();
|
|
385
448
|
push(Route.Main, {
|
|
386
449
|
kind: "llm.tool_call",
|
|
387
450
|
contextId,
|
|
@@ -401,7 +464,64 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
401
464
|
toolName: part.toolName,
|
|
402
465
|
result: part.result,
|
|
403
466
|
});
|
|
467
|
+
passStartedMs = Date.now();
|
|
468
|
+
passTtftRecorded = false;
|
|
469
|
+
push(Route.Main, {
|
|
470
|
+
kind: "metric.conversation",
|
|
471
|
+
contextId,
|
|
472
|
+
timestampMs: passStartedMs,
|
|
473
|
+
name: "llm.call_started",
|
|
474
|
+
value: "1",
|
|
475
|
+
});
|
|
476
|
+
break;
|
|
477
|
+
case "control":
|
|
478
|
+
push(Route.Background, {
|
|
479
|
+
kind: "delegate.result",
|
|
480
|
+
contextId,
|
|
481
|
+
timestampMs: Date.now(),
|
|
482
|
+
query: userText,
|
|
483
|
+
answer: reply,
|
|
484
|
+
durationMs: Date.now() - queryStartedMs,
|
|
485
|
+
grounded,
|
|
486
|
+
control: {
|
|
487
|
+
name: part.name,
|
|
488
|
+
payload: part.payload,
|
|
489
|
+
},
|
|
490
|
+
});
|
|
404
491
|
break;
|
|
492
|
+
case "blocked": {
|
|
493
|
+
if (signal.aborted) return;
|
|
494
|
+
const safeMessage = part.userFacingMessage;
|
|
495
|
+
const blockedMs = Date.now();
|
|
496
|
+
push(Route.Main, {
|
|
497
|
+
kind: "llm.delta",
|
|
498
|
+
contextId,
|
|
499
|
+
timestampMs: blockedMs,
|
|
500
|
+
text: safeMessage,
|
|
501
|
+
});
|
|
502
|
+
push(Route.Main, {
|
|
503
|
+
kind: "llm.done",
|
|
504
|
+
contextId,
|
|
505
|
+
timestampMs: blockedMs,
|
|
506
|
+
text: safeMessage,
|
|
507
|
+
});
|
|
508
|
+
push(Route.Background, {
|
|
509
|
+
kind: "delegate.result",
|
|
510
|
+
contextId,
|
|
511
|
+
timestampMs: blockedMs,
|
|
512
|
+
query: userText,
|
|
513
|
+
answer: safeMessage,
|
|
514
|
+
durationMs: blockedMs - queryStartedMs,
|
|
515
|
+
grounded,
|
|
516
|
+
blocked: {
|
|
517
|
+
userFacingMessage: safeMessage,
|
|
518
|
+
payload: part.payload,
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
defer(() => this.rememberTurn(userText, safeMessage, contextId));
|
|
522
|
+
committed = true;
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
405
525
|
case "error":
|
|
406
526
|
throw part.cause;
|
|
407
527
|
case "finish":
|
|
@@ -412,6 +532,18 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
412
532
|
name: "llm.finish_reason",
|
|
413
533
|
value: part.reason,
|
|
414
534
|
});
|
|
535
|
+
// Record billable token usage — the field the bridge used to drop.
|
|
536
|
+
// A turn with tool calls produces several finish parts; the session
|
|
537
|
+
// accumulator sums them, so emit per-finish rather than once per turn.
|
|
538
|
+
if (part.usage) {
|
|
539
|
+
push(Route.Background, {
|
|
540
|
+
kind: "usage.recorded",
|
|
541
|
+
contextId,
|
|
542
|
+
timestampMs: Date.now(),
|
|
543
|
+
stage: "llm",
|
|
544
|
+
...part.usage,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
415
547
|
finishReason = part.reason;
|
|
416
548
|
break;
|
|
417
549
|
case "suspended": {
|
|
@@ -556,6 +688,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
556
688
|
this.assistantMsgByContext.clear();
|
|
557
689
|
this.wordTimestampsByContext.clear();
|
|
558
690
|
this.playedOutMsByContext.clear();
|
|
691
|
+
this.transientContextMessages.clear();
|
|
559
692
|
this.bus = null;
|
|
560
693
|
}
|
|
561
694
|
|
|
@@ -574,7 +707,14 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
574
707
|
const sessionId = this.opts.sessionId;
|
|
575
708
|
if (!store || !sessionId) return;
|
|
576
709
|
try {
|
|
577
|
-
void Promise.resolve(
|
|
710
|
+
void Promise.resolve(
|
|
711
|
+
store.save(
|
|
712
|
+
sessionId,
|
|
713
|
+
this.history
|
|
714
|
+
.filter((message) => !this.transientContextMessages.has(message as { role: "system"; content: string }))
|
|
715
|
+
.map((message) => ({ ...message })),
|
|
716
|
+
),
|
|
717
|
+
).catch(
|
|
578
718
|
() => undefined,
|
|
579
719
|
);
|
|
580
720
|
} catch {
|
|
@@ -654,6 +794,9 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
654
794
|
for (const [ctx, msg] of this.assistantMsgByContext) {
|
|
655
795
|
if (!this.history.includes(msg)) this.clearTurnState(ctx);
|
|
656
796
|
}
|
|
797
|
+
for (const message of this.transientContextMessages) {
|
|
798
|
+
if (!this.history.includes(message)) this.transientContextMessages.delete(message);
|
|
799
|
+
}
|
|
657
800
|
}
|
|
658
801
|
|
|
659
802
|
private clearTurnState(contextId: string): void {
|
package/src/from-ai-sdk.test.ts
DELETED
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import type { FinishReason, TextStreamPart, ToolSet } from "ai";
|
|
5
|
-
import type { Reasoner, ReasonerTurn, ReasoningPart } from "@kuralle-syrinx/core";
|
|
6
|
-
import { fromAiSdkAgent, fromStreamFactory, type AiSdkAgentLike } from "./from-ai-sdk.js";
|
|
7
|
-
|
|
8
|
-
const ZERO_USAGE = {
|
|
9
|
-
inputTokens: 0,
|
|
10
|
-
inputTokenDetails: {
|
|
11
|
-
noCacheTokens: 0,
|
|
12
|
-
cacheReadTokens: 0,
|
|
13
|
-
cacheWriteTokens: 0,
|
|
14
|
-
},
|
|
15
|
-
outputTokens: 0,
|
|
16
|
-
outputTokenDetails: {
|
|
17
|
-
textTokens: 0,
|
|
18
|
-
reasoningTokens: 0,
|
|
19
|
-
},
|
|
20
|
-
totalTokens: 0,
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
function baseTurn(): ReasonerTurn {
|
|
24
|
-
return {
|
|
25
|
-
userText: "Hi",
|
|
26
|
-
messages: [{ role: "system", content: "test" }],
|
|
27
|
-
signal: new AbortController().signal,
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function textDelta(text: string): TextStreamPart<ToolSet> {
|
|
32
|
-
return { type: "text-delta", id: "0", text, providerMetadata: undefined } as TextStreamPart<ToolSet>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function finish(finishReason: FinishReason, rawFinishReason?: string): TextStreamPart<ToolSet> {
|
|
36
|
-
return {
|
|
37
|
-
type: "finish",
|
|
38
|
-
finishReason,
|
|
39
|
-
rawFinishReason,
|
|
40
|
-
totalUsage: ZERO_USAGE,
|
|
41
|
-
usage: ZERO_USAGE,
|
|
42
|
-
providerMetadata: undefined,
|
|
43
|
-
response: {},
|
|
44
|
-
} as TextStreamPart<ToolSet>;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function finishStep(finishReason: FinishReason, rawFinishReason?: string): TextStreamPart<ToolSet> {
|
|
48
|
-
return {
|
|
49
|
-
type: "finish-step",
|
|
50
|
-
finishReason,
|
|
51
|
-
rawFinishReason,
|
|
52
|
-
usage: ZERO_USAGE,
|
|
53
|
-
providerMetadata: undefined,
|
|
54
|
-
response: {},
|
|
55
|
-
} as TextStreamPart<ToolSet>;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function toolCall(toolCallId: string, toolName: string, input: Record<string, unknown>): TextStreamPart<ToolSet> {
|
|
59
|
-
return {
|
|
60
|
-
type: "tool-call",
|
|
61
|
-
toolCallId,
|
|
62
|
-
toolName,
|
|
63
|
-
input,
|
|
64
|
-
} as TextStreamPart<ToolSet>;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function toolResult(
|
|
68
|
-
toolCallId: string,
|
|
69
|
-
toolName: string,
|
|
70
|
-
output: unknown,
|
|
71
|
-
): TextStreamPart<ToolSet> {
|
|
72
|
-
return {
|
|
73
|
-
type: "tool-result",
|
|
74
|
-
toolCallId,
|
|
75
|
-
toolName,
|
|
76
|
-
input: {},
|
|
77
|
-
output,
|
|
78
|
-
} as TextStreamPart<ToolSet>;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function errorPart(error: unknown): TextStreamPart<ToolSet> {
|
|
82
|
-
return { type: "error", error } as TextStreamPart<ToolSet>;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function toolErrorPart(toolCallId: string, toolName: string, error: unknown): TextStreamPart<ToolSet> {
|
|
86
|
-
return {
|
|
87
|
-
type: "tool-error",
|
|
88
|
-
toolCallId,
|
|
89
|
-
toolName,
|
|
90
|
-
input: {},
|
|
91
|
-
error,
|
|
92
|
-
} as TextStreamPart<ToolSet>;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function collectParts(reasoner: Reasoner, turn: ReasonerTurn): Promise<ReasoningPart[]> {
|
|
96
|
-
const parts: ReasoningPart[] = [];
|
|
97
|
-
for await (const part of reasoner.stream(turn)) {
|
|
98
|
-
parts.push(part);
|
|
99
|
-
}
|
|
100
|
-
return parts;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
describe("from-ai-sdk adapters", () => {
|
|
104
|
-
it("maps happy path: deltas, tool-call, tool-result, finish:stop", async () => {
|
|
105
|
-
const reasoner = fromStreamFactory(async function* () {
|
|
106
|
-
yield textDelta("Hello ");
|
|
107
|
-
yield textDelta("world.");
|
|
108
|
-
yield toolCall("tc-1", "get_weather", { city: "NYC" });
|
|
109
|
-
yield toolResult("tc-1", "get_weather", { temp: 72 });
|
|
110
|
-
yield finish("stop");
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const parts = await collectParts(reasoner, baseTurn());
|
|
114
|
-
|
|
115
|
-
expect(parts).toEqual([
|
|
116
|
-
{ type: "text-delta", text: "Hello " },
|
|
117
|
-
{ type: "text-delta", text: "world." },
|
|
118
|
-
{
|
|
119
|
-
type: "tool-call",
|
|
120
|
-
toolId: "tc-1",
|
|
121
|
-
toolName: "get_weather",
|
|
122
|
-
args: { city: "NYC" },
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
type: "tool-result",
|
|
126
|
-
toolId: "tc-1",
|
|
127
|
-
toolName: "get_weather",
|
|
128
|
-
result: JSON.stringify({ temp: 72 }),
|
|
129
|
-
},
|
|
130
|
-
{ type: "finish", reason: "stop", text: "Hello world." },
|
|
131
|
-
]);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it("maps error part to terminal error", async () => {
|
|
135
|
-
const reasoner = fromStreamFactory(async function* () {
|
|
136
|
-
yield textDelta("partial");
|
|
137
|
-
yield errorPart(new Error("provider failed"));
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
const parts = await collectParts(reasoner, baseTurn());
|
|
141
|
-
|
|
142
|
-
expect(parts).toHaveLength(2);
|
|
143
|
-
expect(parts[0]).toEqual({ type: "text-delta", text: "partial" });
|
|
144
|
-
expect(parts[1]?.type).toBe("error");
|
|
145
|
-
if (parts[1]?.type === "error") {
|
|
146
|
-
expect(parts[1].cause.message).toBe("provider failed");
|
|
147
|
-
expect(parts[1].recoverable).toBe(false);
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("maps tool-error part to terminal error", async () => {
|
|
152
|
-
const reasoner = fromStreamFactory(async function* () {
|
|
153
|
-
yield toolErrorPart("tc-1", "broken_tool", new Error("tool exploded"));
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
const parts = await collectParts(reasoner, baseTurn());
|
|
157
|
-
|
|
158
|
-
expect(parts).toHaveLength(1);
|
|
159
|
-
expect(parts[0]?.type).toBe("error");
|
|
160
|
-
if (parts[0]?.type === "error") {
|
|
161
|
-
expect(parts[0].cause.message).toBe("tool exploded");
|
|
162
|
-
}
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("maps finish-step(error) to terminal error", async () => {
|
|
166
|
-
const reasoner = fromStreamFactory(async function* () {
|
|
167
|
-
yield finishStep("error", "MALFORMED_FUNCTION_CALL");
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
const parts = await collectParts(reasoner, baseTurn());
|
|
171
|
-
|
|
172
|
-
expect(parts).toHaveLength(1);
|
|
173
|
-
expect(parts[0]?.type).toBe("error");
|
|
174
|
-
if (parts[0]?.type === "error") {
|
|
175
|
-
expect(parts[0].cause.message).toBe(
|
|
176
|
-
"AI SDK provider step failed: error (MALFORMED_FUNCTION_CALL)",
|
|
177
|
-
);
|
|
178
|
-
expect(parts[0].recoverable).toBe(true);
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("maps finish(length) to finish with accumulated text", async () => {
|
|
183
|
-
const reasoner = fromStreamFactory(async function* () {
|
|
184
|
-
yield textDelta("truncated");
|
|
185
|
-
yield finish("length", "MAX_TOKENS");
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
const parts = await collectParts(reasoner, baseTurn());
|
|
189
|
-
|
|
190
|
-
expect(parts).toEqual([
|
|
191
|
-
{ type: "text-delta", text: "truncated" },
|
|
192
|
-
{ type: "finish", reason: "length", text: "truncated" },
|
|
193
|
-
]);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it("drops reasoning-delta and tool-input-start parts", async () => {
|
|
197
|
-
const reasoner = fromStreamFactory(async function* () {
|
|
198
|
-
yield {
|
|
199
|
-
type: "reasoning-delta",
|
|
200
|
-
id: "r1",
|
|
201
|
-
text: "thinking...",
|
|
202
|
-
providerMetadata: undefined,
|
|
203
|
-
} as TextStreamPart<ToolSet>;
|
|
204
|
-
yield {
|
|
205
|
-
type: "tool-input-start",
|
|
206
|
-
id: "t1",
|
|
207
|
-
toolName: "search",
|
|
208
|
-
providerMetadata: undefined,
|
|
209
|
-
} as TextStreamPart<ToolSet>;
|
|
210
|
-
yield textDelta("answer");
|
|
211
|
-
yield finish("stop");
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
const parts = await collectParts(reasoner, baseTurn());
|
|
215
|
-
|
|
216
|
-
expect(parts).toEqual([
|
|
217
|
-
{ type: "text-delta", text: "answer" },
|
|
218
|
-
{ type: "finish", reason: "stop", text: "answer" },
|
|
219
|
-
]);
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it("yields first text-delta before source stream completes (no buffering)", async () => {
|
|
223
|
-
let resolveGate: (() => void) | undefined;
|
|
224
|
-
const gate = new Promise<void>((resolve) => {
|
|
225
|
-
resolveGate = resolve;
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
const reasoner = fromStreamFactory(async function* () {
|
|
229
|
-
yield textDelta("immediate");
|
|
230
|
-
await gate;
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
const iterator = reasoner.stream(baseTurn())[Symbol.asyncIterator]();
|
|
234
|
-
const first = await iterator.next();
|
|
235
|
-
|
|
236
|
-
expect(first.done).toBe(false);
|
|
237
|
-
expect(first.value).toEqual({ type: "text-delta", text: "immediate" });
|
|
238
|
-
resolveGate?.();
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it("maps stream ending without finish to terminal error", async () => {
|
|
242
|
-
const reasoner = fromStreamFactory(async function* () {
|
|
243
|
-
yield textDelta("no finish");
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
const parts = await collectParts(reasoner, baseTurn());
|
|
247
|
-
|
|
248
|
-
expect(parts).toHaveLength(2);
|
|
249
|
-
expect(parts[1]?.type).toBe("error");
|
|
250
|
-
if (parts[1]?.type === "error") {
|
|
251
|
-
expect(parts[1].cause.message).toBe("AI SDK stream ended without a provider finish reason");
|
|
252
|
-
}
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it("fromAiSdkAgent maps agent fullStream through the same table", async () => {
|
|
256
|
-
const agent: AiSdkAgentLike = {
|
|
257
|
-
async stream() {
|
|
258
|
-
return {
|
|
259
|
-
fullStream: (async function* () {
|
|
260
|
-
yield textDelta("From ");
|
|
261
|
-
yield textDelta("agent");
|
|
262
|
-
yield finish("stop");
|
|
263
|
-
})(),
|
|
264
|
-
};
|
|
265
|
-
},
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
const parts = await collectParts(fromAiSdkAgent(agent), baseTurn());
|
|
269
|
-
|
|
270
|
-
expect(parts).toEqual([
|
|
271
|
-
{ type: "text-delta", text: "From " },
|
|
272
|
-
{ type: "text-delta", text: "agent" },
|
|
273
|
-
{ type: "finish", reason: "stop", text: "From agent" },
|
|
274
|
-
]);
|
|
275
|
-
});
|
|
276
|
-
});
|