@kuralle-syrinx/aisdk 2.1.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/LICENSE +22 -0
- package/README.md +53 -0
- package/package.json +23 -0
- package/src/from-ai-sdk.test.ts +276 -0
- package/src/from-ai-sdk.ts +226 -0
- package/src/index.test.ts +640 -0
- package/src/index.ts +480 -0
- package/tsconfig.json +13 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kuralle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @kuralle-syrinx/aisdk
|
|
2
|
+
|
|
3
|
+
The LLM stage of the Syrinx cascade as a bus-native `VoicePlugin` — **`ReasoningBridge`** — plus the AI SDK adapters that turn a Vercel AI SDK backend into the normalized [`Reasoner`](../core/README.md#the-reasoner-seam) seam it drives.
|
|
4
|
+
|
|
5
|
+
`ReasoningBridge` consumes `eos.turn_complete`, drives one `reasoner.stream(turn)` per turn, and emits `llm.delta` / `llm.tool_call` / `llm.tool_result` / `llm.done` (or `llm.error` → retry) packets. It owns conversation history, the word-timestamp **spoken-prefix barge-in**, retry, finish-reason validation, and turn-superseding — none of which change when you swap the backend.
|
|
6
|
+
|
|
7
|
+
## Adapters
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { fromAiSdkAgent, fromStreamText, fromStreamFactory } from "@kuralle-syrinx/aisdk";
|
|
11
|
+
|
|
12
|
+
fromAiSdkAgent(agent) // wraps (await agent.stream({messages, abortSignal})).fullStream
|
|
13
|
+
fromStreamText(config) // raw `streamText` config as a Reasoner
|
|
14
|
+
fromStreamFactory(factory) // an AISDKStreamFactory (async generator of TextStreamPart) — the test seam
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
All three map the full `ai@6` `TextStreamPart` union → `ReasoningPart` through one no-buffering generator: `text-delta`/`tool-call`/`tool-result` pass through; `error`/`tool-error`/`abort` and `finish-step`/`finish` with abnormal reasons become a terminal `error` part (driving the bridge's retry/`llm.error` path); everything else is dropped. No buffering — each part is yielded the instant the backend produces it (the §7a latency invariant).
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
23
|
+
import { stepCountIs } from "ai";
|
|
24
|
+
import { ReasoningBridge, fromStreamText } from "@kuralle-syrinx/aisdk";
|
|
25
|
+
|
|
26
|
+
const bridge = new ReasoningBridge(fromStreamText({
|
|
27
|
+
model: createOpenAI({ apiKey })("gpt-4.1-mini"),
|
|
28
|
+
system: "You are a helpful voice assistant.",
|
|
29
|
+
temperature: 0.4,
|
|
30
|
+
maxOutputTokens: 256,
|
|
31
|
+
maxRetries: 0,
|
|
32
|
+
timeout: 30_000,
|
|
33
|
+
stopWhen: stepCountIs(1),
|
|
34
|
+
}));
|
|
35
|
+
session.registerPlugin("bridge", bridge);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The constructor takes a **`Reasoner` only** — wrap your backend explicitly with the matching adapter. There is **no auto-wrap / `.stream()`-probe**. Provider config (model, system, temperature, tools, …) lives in the adapter; the plugin config holds only `timeout_ms` / `max_history_turns` / retry.
|
|
39
|
+
|
|
40
|
+
## Suspend/resume (optional)
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
new ReasoningBridge(reasoner, { runStore, onResumeConflict: "restart" });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- `runStore?: RunStore` — a `{ save(contextId, runId), takePending(contextId), discard(contextId) }` pointer store ("which conversation has a pending suspended run"). Without it, suspend/resume is inert.
|
|
47
|
+
- `onResumeConflict` (default `"restart"`) — on barge-in over a pending run, the pointer is discarded and the question is re-asked fresh (never resume a checkpoint that diverged from the corrected spoken-prefix history). `"replay"` is reserved (throws "not yet supported").
|
|
48
|
+
|
|
49
|
+
A `RunStore` implementation is provided by `@kuralle-syrinx/server-workers-mastra` (`DurableObjectRunStore`, SQL on `ctx.storage.sql`).
|
|
50
|
+
|
|
51
|
+
## Gotcha
|
|
52
|
+
|
|
53
|
+
The bridge is the single source of truth for history (the backend is stateless-per-turn) — this is what makes spoken-prefix barge-in correct. Don't give the backend its own conversation memory; pass `turn.messages`.
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kuralle-syrinx/aisdk",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@ai-sdk/openai": "^3.0.67",
|
|
11
|
+
"ai": "^6.0.0",
|
|
12
|
+
"zod": "^4.1.8",
|
|
13
|
+
"@kuralle-syrinx/core": "2.1.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"typescript": "^5.7.0",
|
|
17
|
+
"vitest": "^2.1.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"test": "vitest run"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Syrinx Kernel v2 — AI SDK → Reasoner adapters
|
|
4
|
+
//
|
|
5
|
+
// Normalizes ai@6 TextStreamPart streams into the Reasoner/ReasoningPart seam.
|
|
6
|
+
// See RFC §4.3 and Sprint 0 PLAN §6.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
streamText,
|
|
10
|
+
type FinishReason,
|
|
11
|
+
type ModelMessage,
|
|
12
|
+
type TextStreamPart,
|
|
13
|
+
type ToolChoice,
|
|
14
|
+
type ToolSet,
|
|
15
|
+
} from "ai";
|
|
16
|
+
import {
|
|
17
|
+
categorizeLlmError,
|
|
18
|
+
isRecoverable,
|
|
19
|
+
type Reasoner,
|
|
20
|
+
type ReasonerMessage,
|
|
21
|
+
type ReasonerTurn,
|
|
22
|
+
type ReasoningPart,
|
|
23
|
+
} from "@kuralle-syrinx/core";
|
|
24
|
+
import type { AISDKStreamFactory } from "./index.js";
|
|
25
|
+
|
|
26
|
+
export interface AiSdkAgentLike {
|
|
27
|
+
stream(opts: {
|
|
28
|
+
messages: ModelMessage[];
|
|
29
|
+
abortSignal: AbortSignal;
|
|
30
|
+
}): Promise<{ fullStream: AsyncIterable<TextStreamPart<ToolSet>> }>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type StreamTextConfig = {
|
|
34
|
+
model: Parameters<typeof streamText>[0]["model"];
|
|
35
|
+
system?: string;
|
|
36
|
+
tools?: ToolSet;
|
|
37
|
+
toolChoice?: ToolChoice<ToolSet>;
|
|
38
|
+
temperature?: number;
|
|
39
|
+
maxOutputTokens?: number;
|
|
40
|
+
maxRetries?: number;
|
|
41
|
+
timeout?: number;
|
|
42
|
+
stopWhen?: Parameters<typeof streamText>[0]["stopWhen"];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function fromAiSdkAgent(agent: AiSdkAgentLike): Reasoner {
|
|
46
|
+
return {
|
|
47
|
+
stream(turn: ReasonerTurn): AsyncIterable<ReasoningPart> {
|
|
48
|
+
return streamFromAgent(agent, turn);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function fromStreamText(config: StreamTextConfig): Reasoner {
|
|
54
|
+
return {
|
|
55
|
+
stream(turn: ReasonerTurn): AsyncIterable<ReasoningPart> {
|
|
56
|
+
return streamFromStreamText(config, turn);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function fromStreamFactory(factory: AISDKStreamFactory): Reasoner {
|
|
62
|
+
return {
|
|
63
|
+
stream(turn: ReasonerTurn): AsyncIterable<ReasoningPart> {
|
|
64
|
+
return streamFromFactory(factory, turn);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function* streamFromAgent(agent: AiSdkAgentLike, turn: ReasonerTurn): AsyncGenerator<ReasoningPart> {
|
|
70
|
+
const messages = buildMessagesForTurn(turn);
|
|
71
|
+
const result = await agent.stream({ messages, abortSignal: turn.signal });
|
|
72
|
+
yield* mapTextStreamParts(result.fullStream);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function* streamFromStreamText(config: StreamTextConfig, turn: ReasonerTurn): AsyncGenerator<ReasoningPart> {
|
|
76
|
+
const messages = buildMessagesForTurn(turn);
|
|
77
|
+
const result = streamText({
|
|
78
|
+
...config,
|
|
79
|
+
messages,
|
|
80
|
+
abortSignal: turn.signal,
|
|
81
|
+
});
|
|
82
|
+
yield* mapTextStreamParts(result.fullStream);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function* streamFromFactory(factory: AISDKStreamFactory, turn: ReasonerTurn): AsyncGenerator<ReasoningPart> {
|
|
86
|
+
const messages = buildMessagesForTurn(turn);
|
|
87
|
+
yield* mapTextStreamParts(factory({ userText: turn.userText, signal: turn.signal, messages }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildMessagesForTurn(turn: ReasonerTurn): ModelMessage[] {
|
|
91
|
+
return [...mapMessages(turn.messages), { role: "user", content: turn.userText }];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mapMessages(messages: readonly ReasonerMessage[]): ModelMessage[] {
|
|
95
|
+
return messages.map((message) => {
|
|
96
|
+
if (message.role === "tool") {
|
|
97
|
+
return {
|
|
98
|
+
role: "tool",
|
|
99
|
+
content: [
|
|
100
|
+
{
|
|
101
|
+
type: "tool-result",
|
|
102
|
+
toolCallId: message.toolCallId ?? "",
|
|
103
|
+
toolName: "",
|
|
104
|
+
output: { type: "text", value: message.content },
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return { role: message.role, content: message.content };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function* mapTextStreamParts(
|
|
114
|
+
source: AsyncIterable<TextStreamPart<ToolSet>>,
|
|
115
|
+
): AsyncGenerator<ReasoningPart> {
|
|
116
|
+
let accumulatedText = "";
|
|
117
|
+
let sawFinish = false;
|
|
118
|
+
|
|
119
|
+
for await (const part of source) {
|
|
120
|
+
switch (part.type) {
|
|
121
|
+
case "text-delta":
|
|
122
|
+
accumulatedText += part.text;
|
|
123
|
+
yield { type: "text-delta", text: part.text };
|
|
124
|
+
break;
|
|
125
|
+
case "tool-call":
|
|
126
|
+
yield {
|
|
127
|
+
type: "tool-call",
|
|
128
|
+
toolId: part.toolCallId,
|
|
129
|
+
toolName: part.toolName,
|
|
130
|
+
args: toRecord(part.input),
|
|
131
|
+
};
|
|
132
|
+
break;
|
|
133
|
+
case "tool-result":
|
|
134
|
+
yield {
|
|
135
|
+
type: "tool-result",
|
|
136
|
+
toolId: part.toolCallId,
|
|
137
|
+
toolName: part.toolName,
|
|
138
|
+
result: stringifyToolOutput(part.output),
|
|
139
|
+
};
|
|
140
|
+
break;
|
|
141
|
+
case "error": {
|
|
142
|
+
const cause = part.error instanceof Error ? part.error : new Error(String(part.error));
|
|
143
|
+
yield toErrorPart(cause);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
case "tool-error": {
|
|
147
|
+
const cause =
|
|
148
|
+
part.error instanceof Error ? part.error : new Error(`Tool ${part.toolName} failed`);
|
|
149
|
+
yield toErrorPart(cause);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
case "abort": {
|
|
153
|
+
const cause = new Error(part.reason ?? "AI SDK stream aborted");
|
|
154
|
+
yield toErrorPart(cause);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
case "finish-step":
|
|
158
|
+
if (part.finishReason === "error" || part.finishReason === "content-filter") {
|
|
159
|
+
yield toErrorPart(
|
|
160
|
+
new Error(
|
|
161
|
+
`AI SDK provider step failed: ${formatFinishReason(part.finishReason, part.rawFinishReason)}`,
|
|
162
|
+
),
|
|
163
|
+
);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
case "finish":
|
|
168
|
+
sawFinish = true;
|
|
169
|
+
if (part.finishReason === "stop" || part.finishReason === "tool-calls" || part.finishReason === "length") {
|
|
170
|
+
yield {
|
|
171
|
+
type: "finish",
|
|
172
|
+
reason: mapFinishReason(part.finishReason),
|
|
173
|
+
text: accumulatedText,
|
|
174
|
+
};
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (
|
|
178
|
+
part.finishReason === "error" ||
|
|
179
|
+
part.finishReason === "content-filter" ||
|
|
180
|
+
part.finishReason === "other" ||
|
|
181
|
+
part.finishReason === "unknown"
|
|
182
|
+
) {
|
|
183
|
+
yield toErrorPart(
|
|
184
|
+
new Error(
|
|
185
|
+
`AI SDK provider did not complete normally: ${formatFinishReason(part.finishReason, part.rawFinishReason)}`,
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
191
|
+
default:
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!sawFinish) {
|
|
197
|
+
yield toErrorPart(new Error("AI SDK stream ended without a provider finish reason"));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function mapFinishReason(finishReason: FinishReason): "stop" | "tool" | "length" {
|
|
202
|
+
if (finishReason === "tool-calls") return "tool";
|
|
203
|
+
if (finishReason === "length") return "length";
|
|
204
|
+
return "stop";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function toErrorPart(cause: Error): ReasoningPart {
|
|
208
|
+
return {
|
|
209
|
+
type: "error",
|
|
210
|
+
cause,
|
|
211
|
+
recoverable: isRecoverable(categorizeLlmError(cause)),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function formatFinishReason(finishReason: FinishReason, rawFinishReason: string | undefined): string {
|
|
216
|
+
return rawFinishReason ? `${finishReason} (${rawFinishReason})` : finishReason;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function toRecord(value: unknown): Record<string, unknown> {
|
|
220
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
|
221
|
+
return value as Record<string, unknown>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function stringifyToolOutput(output: unknown): string {
|
|
225
|
+
return typeof output === "string" ? output : JSON.stringify(output);
|
|
226
|
+
}
|