@kernl-sdk/ai 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,465 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import type {
3
+ LanguageModelV3,
4
+ LanguageModelV3StreamPart,
5
+ } from "@ai-sdk/provider";
6
+
7
+ import { AISDKLanguageModel } from "../language-model";
8
+
9
+ /**
10
+ * Unit tests for AISDKLanguageModel stream accumulation behavior
11
+ */
12
+ describe("AISDKLanguageModel", () => {
13
+ describe("stream - delta accumulation", () => {
14
+ it("should yield delta events and complete Message on text-end", async () => {
15
+ // Mock the underlying AI SDK model
16
+ const mockModel: LanguageModelV3 = {
17
+ provider: "test",
18
+ modelId: "test-model",
19
+ doStream: vi.fn().mockResolvedValue({
20
+ stream: new ReadableStream({
21
+ start(controller) {
22
+ const parts: LanguageModelV3StreamPart[] = [
23
+ {
24
+ type: "text-start",
25
+ id: "text-1",
26
+ providerMetadata: undefined,
27
+ },
28
+ {
29
+ type: "text-delta",
30
+ id: "text-1",
31
+ delta: "Hello",
32
+ providerMetadata: undefined,
33
+ },
34
+ {
35
+ type: "text-delta",
36
+ id: "text-1",
37
+ delta: " ",
38
+ providerMetadata: undefined,
39
+ },
40
+ {
41
+ type: "text-delta",
42
+ id: "text-1",
43
+ delta: "World",
44
+ providerMetadata: undefined,
45
+ },
46
+ {
47
+ type: "text-end",
48
+ id: "text-1",
49
+ providerMetadata: { test: { foo: "bar" } },
50
+ },
51
+ {
52
+ type: "finish",
53
+ finishReason: "stop",
54
+ usage: {
55
+ inputTokens: 5,
56
+ outputTokens: 10,
57
+ totalTokens: 15,
58
+ },
59
+ providerMetadata: undefined,
60
+ },
61
+ ];
62
+
63
+ for (const part of parts) {
64
+ controller.enqueue(part);
65
+ }
66
+ controller.close();
67
+ },
68
+ }),
69
+ }),
70
+ } as any;
71
+
72
+ const model = new AISDKLanguageModel(mockModel);
73
+
74
+ const events = [];
75
+ for await (const event of model.stream({
76
+ input: [
77
+ {
78
+ kind: "message",
79
+ role: "user",
80
+ id: "msg-1",
81
+ content: [{ kind: "text", text: "Hi" }],
82
+ },
83
+ ],
84
+ settings: {},
85
+ })) {
86
+ events.push(event);
87
+ }
88
+
89
+ // Should have: start, 3 deltas, end, complete Message, finish
90
+ expect(events).toHaveLength(7);
91
+
92
+ // Check delta events
93
+ expect(events[0]).toMatchObject({ kind: "text-start", id: "text-1" });
94
+ expect(events[1]).toMatchObject({
95
+ kind: "text-delta",
96
+ id: "text-1",
97
+ text: "Hello",
98
+ });
99
+ expect(events[2]).toMatchObject({
100
+ kind: "text-delta",
101
+ id: "text-1",
102
+ text: " ",
103
+ });
104
+ expect(events[3]).toMatchObject({
105
+ kind: "text-delta",
106
+ id: "text-1",
107
+ text: "World",
108
+ });
109
+
110
+ // Check complete Message item (yielded before end event)
111
+ const messageEvent = events[4];
112
+ expect(messageEvent.kind).toBe("message");
113
+ expect(messageEvent).toMatchObject({
114
+ kind: "message",
115
+ role: "assistant",
116
+ content: [
117
+ {
118
+ kind: "text",
119
+ text: "Hello World", // Accumulated text
120
+ },
121
+ ],
122
+ providerMetadata: { test: { foo: "bar" } }, // From end event
123
+ });
124
+ expect(messageEvent.id).toBeDefined();
125
+
126
+ // Check end event (yielded after Message)
127
+ expect(events[5]).toMatchObject({ kind: "text-end", id: "text-1" });
128
+
129
+ // Check finish event
130
+ expect(events[6]).toMatchObject({ kind: "finish" });
131
+ });
132
+
133
+ it("should yield delta events and complete Reasoning on reasoning-end", async () => {
134
+ const mockModel: LanguageModelV3 = {
135
+ provider: "test",
136
+ modelId: "test-model",
137
+ doStream: vi.fn().mockResolvedValue({
138
+ stream: new ReadableStream({
139
+ start(controller) {
140
+ const parts: LanguageModelV3StreamPart[] = [
141
+ {
142
+ type: "reasoning-start",
143
+ id: "reason-1",
144
+ providerMetadata: undefined,
145
+ },
146
+ {
147
+ type: "reasoning-delta",
148
+ id: "reason-1",
149
+ delta: "Let me think",
150
+ providerMetadata: undefined,
151
+ },
152
+ {
153
+ type: "reasoning-delta",
154
+ id: "reason-1",
155
+ delta: " about this",
156
+ providerMetadata: undefined,
157
+ },
158
+ {
159
+ type: "reasoning-end",
160
+ id: "reason-1",
161
+ providerMetadata: { test: { reasoning: true } },
162
+ },
163
+ {
164
+ type: "finish",
165
+ finishReason: "stop",
166
+ usage: {
167
+ inputTokens: 5,
168
+ outputTokens: 20,
169
+ totalTokens: 25,
170
+ },
171
+ providerMetadata: undefined,
172
+ },
173
+ ];
174
+
175
+ for (const part of parts) {
176
+ controller.enqueue(part);
177
+ }
178
+ controller.close();
179
+ },
180
+ }),
181
+ }),
182
+ } as any;
183
+
184
+ const model = new AISDKLanguageModel(mockModel);
185
+
186
+ const events = [];
187
+ for await (const event of model.stream({
188
+ input: [
189
+ {
190
+ kind: "message",
191
+ role: "user",
192
+ id: "msg-1",
193
+ content: [{ kind: "text", text: "Think about this" }],
194
+ },
195
+ ],
196
+ settings: {},
197
+ })) {
198
+ events.push(event);
199
+ }
200
+
201
+ // Should have: start, 2 deltas, end, complete Reasoning, finish
202
+ expect(events).toHaveLength(6);
203
+
204
+ // Check delta events
205
+ expect(events[0]).toMatchObject({
206
+ kind: "reasoning-start",
207
+ id: "reason-1",
208
+ });
209
+ expect(events[1]).toMatchObject({
210
+ kind: "reasoning-delta",
211
+ id: "reason-1",
212
+ text: "Let me think",
213
+ });
214
+ expect(events[2]).toMatchObject({
215
+ kind: "reasoning-delta",
216
+ id: "reason-1",
217
+ text: " about this",
218
+ });
219
+
220
+ // Check complete Reasoning item (yielded before end event)
221
+ const reasoningEvent = events[3];
222
+ expect(reasoningEvent.kind).toBe("reasoning");
223
+ expect(reasoningEvent).toMatchObject({
224
+ kind: "reasoning",
225
+ text: "Let me think about this", // Accumulated text
226
+ providerMetadata: { test: { reasoning: true } }, // From end event
227
+ });
228
+ expect(reasoningEvent.id).toBeDefined();
229
+
230
+ // Check end event (yielded after Reasoning)
231
+ expect(events[4]).toMatchObject({
232
+ kind: "reasoning-end",
233
+ id: "reason-1",
234
+ });
235
+
236
+ // Check finish event
237
+ expect(events[5]).toMatchObject({ kind: "finish" });
238
+ });
239
+
240
+ it("should handle multiple text blocks with different IDs", async () => {
241
+ const mockModel: LanguageModelV3 = {
242
+ provider: "test",
243
+ modelId: "test-model",
244
+ doStream: vi.fn().mockResolvedValue({
245
+ stream: new ReadableStream({
246
+ start(controller) {
247
+ const parts: LanguageModelV3StreamPart[] = [
248
+ { type: "text-start", id: "text-1", providerMetadata: undefined },
249
+ {
250
+ type: "text-delta",
251
+ id: "text-1",
252
+ delta: "First",
253
+ providerMetadata: undefined,
254
+ },
255
+ {
256
+ type: "text-end",
257
+ id: "text-1",
258
+ providerMetadata: { test: { order: 1 } },
259
+ },
260
+ { type: "text-start", id: "text-2", providerMetadata: undefined },
261
+ {
262
+ type: "text-delta",
263
+ id: "text-2",
264
+ delta: "Second",
265
+ providerMetadata: undefined,
266
+ },
267
+ {
268
+ type: "text-end",
269
+ id: "text-2",
270
+ providerMetadata: { test: { order: 2 } },
271
+ },
272
+ {
273
+ type: "finish",
274
+ finishReason: "stop",
275
+ usage: {
276
+ inputTokens: 5,
277
+ outputTokens: 10,
278
+ totalTokens: 15,
279
+ },
280
+ providerMetadata: undefined,
281
+ },
282
+ ];
283
+
284
+ for (const part of parts) {
285
+ controller.enqueue(part);
286
+ }
287
+ controller.close();
288
+ },
289
+ }),
290
+ }),
291
+ } as any;
292
+
293
+ const model = new AISDKLanguageModel(mockModel);
294
+
295
+ const events = [];
296
+ for await (const event of model.stream({
297
+ input: [
298
+ {
299
+ kind: "message",
300
+ role: "user",
301
+ id: "msg-1",
302
+ content: [{ kind: "text", text: "Hi" }],
303
+ },
304
+ ],
305
+ settings: {},
306
+ })) {
307
+ events.push(event);
308
+ }
309
+
310
+ // Check we got two separate Message items
311
+ const messageEvents = events.filter((e) => e.kind === "message");
312
+ expect(messageEvents).toHaveLength(2);
313
+
314
+ expect(messageEvents[0]).toMatchObject({
315
+ kind: "message",
316
+ role: "assistant",
317
+ content: [{ kind: "text", text: "First" }],
318
+ providerMetadata: { test: { order: 1 } },
319
+ });
320
+
321
+ expect(messageEvents[1]).toMatchObject({
322
+ kind: "message",
323
+ role: "assistant",
324
+ content: [{ kind: "text", text: "Second" }],
325
+ providerMetadata: { test: { order: 2 } },
326
+ });
327
+ });
328
+
329
+ it("should use metadata from end event (last-wins)", async () => {
330
+ const mockModel: LanguageModelV3 = {
331
+ provider: "test",
332
+ modelId: "test-model",
333
+ doStream: vi.fn().mockResolvedValue({
334
+ stream: new ReadableStream({
335
+ start(controller) {
336
+ const parts: LanguageModelV3StreamPart[] = [
337
+ {
338
+ type: "text-start",
339
+ id: "text-1",
340
+ providerMetadata: { test: { version: "start" } },
341
+ },
342
+ {
343
+ type: "text-delta",
344
+ id: "text-1",
345
+ delta: "Test",
346
+ providerMetadata: { test: { version: "delta" } },
347
+ },
348
+ {
349
+ type: "text-end",
350
+ id: "text-1",
351
+ providerMetadata: { test: { version: "end" } },
352
+ },
353
+ {
354
+ type: "finish",
355
+ finishReason: "stop",
356
+ usage: {
357
+ inputTokens: 5,
358
+ outputTokens: 10,
359
+ totalTokens: 15,
360
+ },
361
+ providerMetadata: undefined,
362
+ },
363
+ ];
364
+
365
+ for (const part of parts) {
366
+ controller.enqueue(part);
367
+ }
368
+ controller.close();
369
+ },
370
+ }),
371
+ }),
372
+ } as any;
373
+
374
+ const model = new AISDKLanguageModel(mockModel);
375
+
376
+ const events = [];
377
+ for await (const event of model.stream({
378
+ input: [
379
+ {
380
+ kind: "message",
381
+ role: "user",
382
+ id: "msg-1",
383
+ content: [{ kind: "text", text: "Hi" }],
384
+ },
385
+ ],
386
+ settings: {},
387
+ })) {
388
+ events.push(event);
389
+ }
390
+
391
+ // Check complete Message has metadata from end event
392
+ const messageEvent = events.find((e) => e.kind === "message");
393
+ expect(messageEvent).toMatchObject({
394
+ providerMetadata: { test: { version: "end" } }, // From end event, not start/delta
395
+ });
396
+ });
397
+
398
+ it("should pass through tool-call events unchanged", async () => {
399
+ const mockModel: LanguageModelV3 = {
400
+ provider: "test",
401
+ modelId: "test-model",
402
+ doStream: vi.fn().mockResolvedValue({
403
+ stream: new ReadableStream({
404
+ start(controller) {
405
+ const parts: LanguageModelV3StreamPart[] = [
406
+ {
407
+ type: "tool-call",
408
+ toolCallId: "call-123",
409
+ toolName: "calculator",
410
+ input: '{"expression":"2+2"}',
411
+ providerMetadata: undefined,
412
+ },
413
+ {
414
+ type: "finish",
415
+ finishReason: "tool-calls",
416
+ usage: {
417
+ inputTokens: 5,
418
+ outputTokens: 10,
419
+ totalTokens: 15,
420
+ },
421
+ providerMetadata: undefined,
422
+ },
423
+ ];
424
+
425
+ for (const part of parts) {
426
+ controller.enqueue(part);
427
+ }
428
+ controller.close();
429
+ },
430
+ }),
431
+ }),
432
+ } as any;
433
+
434
+ const model = new AISDKLanguageModel(mockModel);
435
+
436
+ const events = [];
437
+ for await (const event of model.stream({
438
+ input: [
439
+ {
440
+ kind: "message",
441
+ role: "user",
442
+ id: "msg-1",
443
+ content: [{ kind: "text", text: "Calculate 2+2" }],
444
+ },
445
+ ],
446
+ settings: {},
447
+ })) {
448
+ events.push(event);
449
+ }
450
+
451
+ // Should have tool-call and finish
452
+ expect(events).toHaveLength(2);
453
+
454
+ expect(events[0]).toMatchObject({
455
+ kind: "tool-call",
456
+ callId: "call-123",
457
+ toolId: "calculator",
458
+ state: "completed",
459
+ arguments: '{"expression":"2+2"}',
460
+ });
461
+
462
+ expect(events[1]).toMatchObject({ kind: "finish" });
463
+ });
464
+ });
465
+ });
@@ -226,7 +226,7 @@ describe("MESSAGE codec", () => {
226
226
  type: "tool-call",
227
227
  toolCallId: "call-123",
228
228
  toolName: "get_weather",
229
- input: { city: "SF" },
229
+ input: JSON.stringify({ city: "SF" }),
230
230
  providerOptions: undefined,
231
231
  },
232
232
  ],
@@ -112,7 +112,7 @@ export const MESSAGE: Codec<LanguageModelItem, LanguageModelV3Message> = {
112
112
  type: "tool-call",
113
113
  toolCallId: item.callId,
114
114
  toolName: item.toolId,
115
- input: JSON.parse(item.arguments),
115
+ input: item.arguments,
116
116
  providerOptions: item.providerMetadata,
117
117
  },
118
118
  ],
@@ -62,7 +62,7 @@ export const MODEL_RESPONSE: Codec<LanguageModelResponse, AISdkGenerateResult> =
62
62
  callId: item.toolCallId,
63
63
  toolId: item.toolName,
64
64
  state: "completed",
65
- arguments: JSON.stringify(item.input),
65
+ arguments: item.input,
66
66
  providerMetadata: item.providerMetadata,
67
67
  });
68
68
  } else if (item.type === "file") {
@@ -1,5 +1,6 @@
1
1
  import type { Codec, LanguageModelStreamEvent } from "@kernl-sdk/protocol";
2
2
  import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
3
+ import { COMPLETED, FAILED } from "@kernl-sdk/protocol";
3
4
  import { WARNING } from "./response";
4
5
 
5
6
  /**
@@ -109,8 +110,9 @@ export const STREAM_PART: Codec<
109
110
  case "tool-call":
110
111
  return {
111
112
  kind: "tool-call",
112
- id: part.toolCallId,
113
- toolName: part.toolName,
113
+ callId: part.toolCallId,
114
+ toolId: part.toolName,
115
+ state: COMPLETED,
114
116
  arguments: part.input,
115
117
  providerMetadata: part.providerMetadata,
116
118
  };
@@ -121,7 +123,7 @@ export const STREAM_PART: Codec<
121
123
  kind: "tool-result",
122
124
  callId: part.toolCallId,
123
125
  toolId: part.toolName,
124
- state: part.isError ? "failed" : "completed",
126
+ state: part.isError ? FAILED : COMPLETED,
125
127
  result: part.result,
126
128
  error: part.isError ? String(part.result) : null,
127
129
  providerMetadata: part.providerMetadata,
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @kernl-sdk/ai - AI SDK adapter for Kernl
2
+ * @kernl-sdk/ai - AI SDK adapter for kernl
3
3
  *
4
4
  * Universal provider support via Vercel AI SDK v5.
5
5
  *
@@ -6,6 +6,7 @@ import type {
6
6
  LanguageModelResponse,
7
7
  LanguageModelStreamEvent,
8
8
  } from "@kernl-sdk/protocol";
9
+ import { message, reasoning } from "@kernl-sdk/protocol";
9
10
 
10
11
  import { MESSAGE } from "./convert/message";
11
12
  import { TOOL } from "./convert/tools";
@@ -69,7 +70,73 @@ export class AISDKLanguageModel implements LanguageModel {
69
70
  abortSignal: request.abort,
70
71
  });
71
72
 
72
- yield* convertStream(stream.stream);
73
+ // text and reasoning buffers for delta accumulation
74
+ const tbuf: Record<string, string> = {};
75
+ const rbuf: Record<string, string> = {};
76
+
77
+ for await (const event of convertStream(stream.stream)) {
78
+ switch (event.kind) {
79
+ case "text-start": {
80
+ tbuf[event.id] = "";
81
+ yield event;
82
+ break;
83
+ }
84
+
85
+ case "text-delta": {
86
+ if (tbuf[event.id] !== undefined) {
87
+ tbuf[event.id] += event.text;
88
+ }
89
+ yield event;
90
+ break;
91
+ }
92
+
93
+ case "text-end": {
94
+ const text = tbuf[event.id];
95
+ if (text !== undefined) {
96
+ yield message({
97
+ role: "assistant",
98
+ text,
99
+ providerMetadata: event.providerMetadata,
100
+ });
101
+ delete tbuf[event.id];
102
+ }
103
+ yield event;
104
+ break;
105
+ }
106
+
107
+ case "reasoning-start": {
108
+ rbuf[event.id] = "";
109
+ yield event;
110
+ break;
111
+ }
112
+
113
+ case "reasoning-delta": {
114
+ if (rbuf[event.id] !== undefined) {
115
+ rbuf[event.id] += event.text;
116
+ }
117
+ yield event;
118
+ break;
119
+ }
120
+
121
+ case "reasoning-end": {
122
+ const text = rbuf[event.id];
123
+ if (text !== undefined) {
124
+ yield reasoning({
125
+ text,
126
+ providerMetadata: event.providerMetadata,
127
+ });
128
+ delete rbuf[event.id];
129
+ }
130
+ yield event;
131
+ break;
132
+ }
133
+
134
+ default:
135
+ // all other events (tool-call, tool-result, finish, etc.) pass through
136
+ yield event;
137
+ break;
138
+ }
139
+ }
73
140
  } catch (error) {
74
141
  throw wrapError(error, "stream");
75
142
  }
@@ -2,7 +2,7 @@ import { anthropic as createAnthropicModel } from "@ai-sdk/anthropic";
2
2
  import { AISDKLanguageModel } from "../language-model";
3
3
 
4
4
  /**
5
- * Create a Kernl-compatible Anthropic language model.
5
+ * Create a kernl-compatible Anthropic language model.
6
6
  *
7
7
  * @example
8
8
  * ```ts
@@ -2,7 +2,7 @@ import { google as createGoogleModel } from "@ai-sdk/google";
2
2
  import { AISDKLanguageModel } from "../language-model";
3
3
 
4
4
  /**
5
- * Create a Kernl-compatible Google Generative AI language model.
5
+ * Create a kernl-compatible Google Generative AI language model.
6
6
  *
7
7
  * @example
8
8
  * ```ts
@@ -2,7 +2,7 @@ import { openai as createOpenAIModel } from "@ai-sdk/openai";
2
2
  import { AISDKLanguageModel } from "../language-model";
3
3
 
4
4
  /**
5
- * Create a Kernl-compatible OpenAI language model.
5
+ * Create a kernl-compatible OpenAI language model.
6
6
  *
7
7
  * @example
8
8
  * ```ts