@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.
@@ -1,4 +1,4 @@
1
1
 
2
- > @kernl-sdk/ai@0.1.1 build /Users/andjones/Documents/projects/kernl/packages/_ai
2
+ > @kernl-sdk/ai@0.1.2 build /Users/andjones/Documents/projects/kernl/packages/_ai
3
3
  > tsc && tsc-alias
4
4
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # @kernl/ai
2
2
 
3
+ ## 0.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Minor bug fixes and improvements
8
+ - Updated dependencies
9
+ - @kernl-sdk/protocol@0.2.1
10
+ - @kernl-sdk/shared@0.1.2
11
+
3
12
  ## 0.1.1
4
13
 
5
14
  ### Patch Changes
@@ -221,6 +221,57 @@ describe.skipIf(SKIP_INTEGRATION_TESTS)("AISDKLanguageModel integration", () =>
221
221
  const finishEvents = events.filter((e) => e.kind === "finish");
222
222
  expect(finishEvents.length).toBe(1);
223
223
  });
224
+ it("should yield both delta events and complete Message items", async () => {
225
+ const events = [];
226
+ for await (const event of gpt4omini.stream({
227
+ input: [
228
+ {
229
+ kind: "message",
230
+ role: "user",
231
+ id: "msg-1",
232
+ content: [
233
+ { kind: "text", text: "Say 'Hello World' and nothing else." },
234
+ ],
235
+ },
236
+ ],
237
+ settings: {
238
+ maxTokens: 50,
239
+ temperature: 0,
240
+ },
241
+ })) {
242
+ events.push(event);
243
+ }
244
+ expect(events.length).toBeGreaterThan(0);
245
+ // Should have text-delta events (for streaming UX)
246
+ const textDeltas = events.filter((e) => e.kind === "text-delta");
247
+ expect(textDeltas.length).toBeGreaterThan(0);
248
+ // Should have text-start event
249
+ const textStarts = events.filter((e) => e.kind === "text-start");
250
+ expect(textStarts.length).toBeGreaterThan(0);
251
+ // Should have text-end event
252
+ const textEnds = events.filter((e) => e.kind === "text-end");
253
+ expect(textEnds.length).toBeGreaterThan(0);
254
+ // Should have complete Message item (for history)
255
+ const messages = events.filter((e) => e.kind === "message");
256
+ expect(messages.length).toBeGreaterThan(0);
257
+ const assistantMessage = messages[0];
258
+ expect(assistantMessage.role).toBe("assistant");
259
+ expect(assistantMessage.content).toBeDefined();
260
+ expect(assistantMessage.content.length).toBeGreaterThan(0);
261
+ // Message should have accumulated text from all deltas
262
+ const textContent = assistantMessage.content.find((c) => c.kind === "text");
263
+ expect(textContent).toBeDefined();
264
+ expect(textContent.text).toBeDefined();
265
+ expect(textContent.text.length).toBeGreaterThan(0);
266
+ // Verify accumulated text matches concatenated deltas
267
+ const accumulatedFromDeltas = textDeltas
268
+ .map((d) => d.text)
269
+ .join("");
270
+ expect(textContent.text).toBe(accumulatedFromDeltas);
271
+ // Should have finish event
272
+ const finishEvents = events.filter((e) => e.kind === "finish");
273
+ expect(finishEvents.length).toBe(1);
274
+ });
224
275
  });
225
276
  describe("tools", () => {
226
277
  it("should call tools when requested", async () => {
@@ -350,6 +401,92 @@ describe.skipIf(SKIP_INTEGRATION_TESTS)("AISDKLanguageModel integration", () =>
350
401
  const toolCalls = response.content.filter((item) => item.kind === "tool-call");
351
402
  expect(toolCalls.length).toBeGreaterThan(0);
352
403
  });
404
+ it("should handle multi-turn conversation with tool results", async () => {
405
+ // First turn: get tool calls from the model
406
+ const firstResponse = await gpt4omini.generate({
407
+ input: [
408
+ {
409
+ kind: "message",
410
+ role: "user",
411
+ id: "msg-1",
412
+ content: [{ kind: "text", text: "What is 25 + 17?" }],
413
+ },
414
+ ],
415
+ tools: [
416
+ {
417
+ kind: "function",
418
+ name: "calculate",
419
+ description: "Perform a mathematical calculation",
420
+ parameters: {
421
+ type: "object",
422
+ properties: {
423
+ expression: {
424
+ type: "string",
425
+ description: "The mathematical expression to evaluate",
426
+ },
427
+ },
428
+ required: ["expression"],
429
+ },
430
+ },
431
+ ],
432
+ settings: {
433
+ maxTokens: 200,
434
+ temperature: 0,
435
+ },
436
+ });
437
+ expect(firstResponse.content).toBeDefined();
438
+ // Extract tool calls
439
+ const toolCalls = firstResponse.content.filter((item) => item.kind === "tool-call");
440
+ expect(toolCalls.length).toBeGreaterThan(0);
441
+ const toolCall = toolCalls[0];
442
+ expect(toolCall.callId).toBeDefined();
443
+ expect(toolCall.toolId).toBe("calculate");
444
+ // Second turn: send tool results back to the model
445
+ const secondResponse = await gpt4omini.generate({
446
+ input: [
447
+ {
448
+ kind: "message",
449
+ role: "user",
450
+ id: "msg-1",
451
+ content: [{ kind: "text", text: "What is 25 + 17?" }],
452
+ },
453
+ ...firstResponse.content,
454
+ {
455
+ kind: "tool-result",
456
+ callId: toolCall.callId,
457
+ toolId: toolCall.toolId,
458
+ state: "completed",
459
+ result: { answer: 42 },
460
+ error: null,
461
+ },
462
+ ],
463
+ tools: [
464
+ {
465
+ kind: "function",
466
+ name: "calculate",
467
+ description: "Perform a mathematical calculation",
468
+ parameters: {
469
+ type: "object",
470
+ properties: {
471
+ expression: {
472
+ type: "string",
473
+ description: "The mathematical expression to evaluate",
474
+ },
475
+ },
476
+ required: ["expression"],
477
+ },
478
+ },
479
+ ],
480
+ settings: {
481
+ maxTokens: 200,
482
+ temperature: 0,
483
+ },
484
+ });
485
+ expect(secondResponse.content).toBeDefined();
486
+ // Should have an assistant message with the final answer
487
+ const messages = secondResponse.content.filter((item) => item.kind === "message" && item.role === "assistant");
488
+ expect(messages.length).toBeGreaterThan(0);
489
+ });
353
490
  });
354
491
  describe("validation", () => {
355
492
  it("should throw error for invalid maxTokens", async () => {
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=language-model.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"language-model.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/language-model.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,423 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { AISDKLanguageModel } from "../language-model";
3
+ /**
4
+ * Unit tests for AISDKLanguageModel stream accumulation behavior
5
+ */
6
+ describe("AISDKLanguageModel", () => {
7
+ describe("stream - delta accumulation", () => {
8
+ it("should yield delta events and complete Message on text-end", async () => {
9
+ // Mock the underlying AI SDK model
10
+ const mockModel = {
11
+ provider: "test",
12
+ modelId: "test-model",
13
+ doStream: vi.fn().mockResolvedValue({
14
+ stream: new ReadableStream({
15
+ start(controller) {
16
+ const parts = [
17
+ {
18
+ type: "text-start",
19
+ id: "text-1",
20
+ providerMetadata: undefined,
21
+ },
22
+ {
23
+ type: "text-delta",
24
+ id: "text-1",
25
+ delta: "Hello",
26
+ providerMetadata: undefined,
27
+ },
28
+ {
29
+ type: "text-delta",
30
+ id: "text-1",
31
+ delta: " ",
32
+ providerMetadata: undefined,
33
+ },
34
+ {
35
+ type: "text-delta",
36
+ id: "text-1",
37
+ delta: "World",
38
+ providerMetadata: undefined,
39
+ },
40
+ {
41
+ type: "text-end",
42
+ id: "text-1",
43
+ providerMetadata: { test: { foo: "bar" } },
44
+ },
45
+ {
46
+ type: "finish",
47
+ finishReason: "stop",
48
+ usage: {
49
+ inputTokens: 5,
50
+ outputTokens: 10,
51
+ totalTokens: 15,
52
+ },
53
+ providerMetadata: undefined,
54
+ },
55
+ ];
56
+ for (const part of parts) {
57
+ controller.enqueue(part);
58
+ }
59
+ controller.close();
60
+ },
61
+ }),
62
+ }),
63
+ };
64
+ const model = new AISDKLanguageModel(mockModel);
65
+ const events = [];
66
+ for await (const event of model.stream({
67
+ input: [
68
+ {
69
+ kind: "message",
70
+ role: "user",
71
+ id: "msg-1",
72
+ content: [{ kind: "text", text: "Hi" }],
73
+ },
74
+ ],
75
+ settings: {},
76
+ })) {
77
+ events.push(event);
78
+ }
79
+ // Should have: start, 3 deltas, end, complete Message, finish
80
+ expect(events).toHaveLength(7);
81
+ // Check delta events
82
+ expect(events[0]).toMatchObject({ kind: "text-start", id: "text-1" });
83
+ expect(events[1]).toMatchObject({
84
+ kind: "text-delta",
85
+ id: "text-1",
86
+ text: "Hello",
87
+ });
88
+ expect(events[2]).toMatchObject({
89
+ kind: "text-delta",
90
+ id: "text-1",
91
+ text: " ",
92
+ });
93
+ expect(events[3]).toMatchObject({
94
+ kind: "text-delta",
95
+ id: "text-1",
96
+ text: "World",
97
+ });
98
+ // Check complete Message item (yielded before end event)
99
+ const messageEvent = events[4];
100
+ expect(messageEvent.kind).toBe("message");
101
+ expect(messageEvent).toMatchObject({
102
+ kind: "message",
103
+ role: "assistant",
104
+ content: [
105
+ {
106
+ kind: "text",
107
+ text: "Hello World", // Accumulated text
108
+ },
109
+ ],
110
+ providerMetadata: { test: { foo: "bar" } }, // From end event
111
+ });
112
+ expect(messageEvent.id).toBeDefined();
113
+ // Check end event (yielded after Message)
114
+ expect(events[5]).toMatchObject({ kind: "text-end", id: "text-1" });
115
+ // Check finish event
116
+ expect(events[6]).toMatchObject({ kind: "finish" });
117
+ });
118
+ it("should yield delta events and complete Reasoning on reasoning-end", async () => {
119
+ const mockModel = {
120
+ provider: "test",
121
+ modelId: "test-model",
122
+ doStream: vi.fn().mockResolvedValue({
123
+ stream: new ReadableStream({
124
+ start(controller) {
125
+ const parts = [
126
+ {
127
+ type: "reasoning-start",
128
+ id: "reason-1",
129
+ providerMetadata: undefined,
130
+ },
131
+ {
132
+ type: "reasoning-delta",
133
+ id: "reason-1",
134
+ delta: "Let me think",
135
+ providerMetadata: undefined,
136
+ },
137
+ {
138
+ type: "reasoning-delta",
139
+ id: "reason-1",
140
+ delta: " about this",
141
+ providerMetadata: undefined,
142
+ },
143
+ {
144
+ type: "reasoning-end",
145
+ id: "reason-1",
146
+ providerMetadata: { test: { reasoning: true } },
147
+ },
148
+ {
149
+ type: "finish",
150
+ finishReason: "stop",
151
+ usage: {
152
+ inputTokens: 5,
153
+ outputTokens: 20,
154
+ totalTokens: 25,
155
+ },
156
+ providerMetadata: undefined,
157
+ },
158
+ ];
159
+ for (const part of parts) {
160
+ controller.enqueue(part);
161
+ }
162
+ controller.close();
163
+ },
164
+ }),
165
+ }),
166
+ };
167
+ const model = new AISDKLanguageModel(mockModel);
168
+ const events = [];
169
+ for await (const event of model.stream({
170
+ input: [
171
+ {
172
+ kind: "message",
173
+ role: "user",
174
+ id: "msg-1",
175
+ content: [{ kind: "text", text: "Think about this" }],
176
+ },
177
+ ],
178
+ settings: {},
179
+ })) {
180
+ events.push(event);
181
+ }
182
+ // Should have: start, 2 deltas, end, complete Reasoning, finish
183
+ expect(events).toHaveLength(6);
184
+ // Check delta events
185
+ expect(events[0]).toMatchObject({
186
+ kind: "reasoning-start",
187
+ id: "reason-1",
188
+ });
189
+ expect(events[1]).toMatchObject({
190
+ kind: "reasoning-delta",
191
+ id: "reason-1",
192
+ text: "Let me think",
193
+ });
194
+ expect(events[2]).toMatchObject({
195
+ kind: "reasoning-delta",
196
+ id: "reason-1",
197
+ text: " about this",
198
+ });
199
+ // Check complete Reasoning item (yielded before end event)
200
+ const reasoningEvent = events[3];
201
+ expect(reasoningEvent.kind).toBe("reasoning");
202
+ expect(reasoningEvent).toMatchObject({
203
+ kind: "reasoning",
204
+ text: "Let me think about this", // Accumulated text
205
+ providerMetadata: { test: { reasoning: true } }, // From end event
206
+ });
207
+ expect(reasoningEvent.id).toBeDefined();
208
+ // Check end event (yielded after Reasoning)
209
+ expect(events[4]).toMatchObject({
210
+ kind: "reasoning-end",
211
+ id: "reason-1",
212
+ });
213
+ // Check finish event
214
+ expect(events[5]).toMatchObject({ kind: "finish" });
215
+ });
216
+ it("should handle multiple text blocks with different IDs", async () => {
217
+ const mockModel = {
218
+ provider: "test",
219
+ modelId: "test-model",
220
+ doStream: vi.fn().mockResolvedValue({
221
+ stream: new ReadableStream({
222
+ start(controller) {
223
+ const parts = [
224
+ { type: "text-start", id: "text-1", providerMetadata: undefined },
225
+ {
226
+ type: "text-delta",
227
+ id: "text-1",
228
+ delta: "First",
229
+ providerMetadata: undefined,
230
+ },
231
+ {
232
+ type: "text-end",
233
+ id: "text-1",
234
+ providerMetadata: { test: { order: 1 } },
235
+ },
236
+ { type: "text-start", id: "text-2", providerMetadata: undefined },
237
+ {
238
+ type: "text-delta",
239
+ id: "text-2",
240
+ delta: "Second",
241
+ providerMetadata: undefined,
242
+ },
243
+ {
244
+ type: "text-end",
245
+ id: "text-2",
246
+ providerMetadata: { test: { order: 2 } },
247
+ },
248
+ {
249
+ type: "finish",
250
+ finishReason: "stop",
251
+ usage: {
252
+ inputTokens: 5,
253
+ outputTokens: 10,
254
+ totalTokens: 15,
255
+ },
256
+ providerMetadata: undefined,
257
+ },
258
+ ];
259
+ for (const part of parts) {
260
+ controller.enqueue(part);
261
+ }
262
+ controller.close();
263
+ },
264
+ }),
265
+ }),
266
+ };
267
+ const model = new AISDKLanguageModel(mockModel);
268
+ const events = [];
269
+ for await (const event of model.stream({
270
+ input: [
271
+ {
272
+ kind: "message",
273
+ role: "user",
274
+ id: "msg-1",
275
+ content: [{ kind: "text", text: "Hi" }],
276
+ },
277
+ ],
278
+ settings: {},
279
+ })) {
280
+ events.push(event);
281
+ }
282
+ // Check we got two separate Message items
283
+ const messageEvents = events.filter((e) => e.kind === "message");
284
+ expect(messageEvents).toHaveLength(2);
285
+ expect(messageEvents[0]).toMatchObject({
286
+ kind: "message",
287
+ role: "assistant",
288
+ content: [{ kind: "text", text: "First" }],
289
+ providerMetadata: { test: { order: 1 } },
290
+ });
291
+ expect(messageEvents[1]).toMatchObject({
292
+ kind: "message",
293
+ role: "assistant",
294
+ content: [{ kind: "text", text: "Second" }],
295
+ providerMetadata: { test: { order: 2 } },
296
+ });
297
+ });
298
+ it("should use metadata from end event (last-wins)", async () => {
299
+ const mockModel = {
300
+ provider: "test",
301
+ modelId: "test-model",
302
+ doStream: vi.fn().mockResolvedValue({
303
+ stream: new ReadableStream({
304
+ start(controller) {
305
+ const parts = [
306
+ {
307
+ type: "text-start",
308
+ id: "text-1",
309
+ providerMetadata: { test: { version: "start" } },
310
+ },
311
+ {
312
+ type: "text-delta",
313
+ id: "text-1",
314
+ delta: "Test",
315
+ providerMetadata: { test: { version: "delta" } },
316
+ },
317
+ {
318
+ type: "text-end",
319
+ id: "text-1",
320
+ providerMetadata: { test: { version: "end" } },
321
+ },
322
+ {
323
+ type: "finish",
324
+ finishReason: "stop",
325
+ usage: {
326
+ inputTokens: 5,
327
+ outputTokens: 10,
328
+ totalTokens: 15,
329
+ },
330
+ providerMetadata: undefined,
331
+ },
332
+ ];
333
+ for (const part of parts) {
334
+ controller.enqueue(part);
335
+ }
336
+ controller.close();
337
+ },
338
+ }),
339
+ }),
340
+ };
341
+ const model = new AISDKLanguageModel(mockModel);
342
+ const events = [];
343
+ for await (const event of model.stream({
344
+ input: [
345
+ {
346
+ kind: "message",
347
+ role: "user",
348
+ id: "msg-1",
349
+ content: [{ kind: "text", text: "Hi" }],
350
+ },
351
+ ],
352
+ settings: {},
353
+ })) {
354
+ events.push(event);
355
+ }
356
+ // Check complete Message has metadata from end event
357
+ const messageEvent = events.find((e) => e.kind === "message");
358
+ expect(messageEvent).toMatchObject({
359
+ providerMetadata: { test: { version: "end" } }, // From end event, not start/delta
360
+ });
361
+ });
362
+ it("should pass through tool-call events unchanged", async () => {
363
+ const mockModel = {
364
+ provider: "test",
365
+ modelId: "test-model",
366
+ doStream: vi.fn().mockResolvedValue({
367
+ stream: new ReadableStream({
368
+ start(controller) {
369
+ const parts = [
370
+ {
371
+ type: "tool-call",
372
+ toolCallId: "call-123",
373
+ toolName: "calculator",
374
+ input: '{"expression":"2+2"}',
375
+ providerMetadata: undefined,
376
+ },
377
+ {
378
+ type: "finish",
379
+ finishReason: "tool-calls",
380
+ usage: {
381
+ inputTokens: 5,
382
+ outputTokens: 10,
383
+ totalTokens: 15,
384
+ },
385
+ providerMetadata: undefined,
386
+ },
387
+ ];
388
+ for (const part of parts) {
389
+ controller.enqueue(part);
390
+ }
391
+ controller.close();
392
+ },
393
+ }),
394
+ }),
395
+ };
396
+ const model = new AISDKLanguageModel(mockModel);
397
+ const events = [];
398
+ for await (const event of model.stream({
399
+ input: [
400
+ {
401
+ kind: "message",
402
+ role: "user",
403
+ id: "msg-1",
404
+ content: [{ kind: "text", text: "Calculate 2+2" }],
405
+ },
406
+ ],
407
+ settings: {},
408
+ })) {
409
+ events.push(event);
410
+ }
411
+ // Should have tool-call and finish
412
+ expect(events).toHaveLength(2);
413
+ expect(events[0]).toMatchObject({
414
+ kind: "tool-call",
415
+ callId: "call-123",
416
+ toolId: "calculator",
417
+ state: "completed",
418
+ arguments: '{"expression":"2+2"}',
419
+ });
420
+ expect(events[1]).toMatchObject({ kind: "finish" });
421
+ });
422
+ });
423
+ });
@@ -204,7 +204,7 @@ describe("MESSAGE codec", () => {
204
204
  type: "tool-call",
205
205
  toolCallId: "call-123",
206
206
  toolName: "get_weather",
207
- input: { city: "SF" },
207
+ input: JSON.stringify({ city: "SF" }),
208
208
  providerOptions: undefined,
209
209
  },
210
210
  ],
@@ -89,7 +89,7 @@ export const MESSAGE = {
89
89
  type: "tool-call",
90
90
  toolCallId: item.callId,
91
91
  toolName: item.toolId,
92
- input: JSON.parse(item.arguments),
92
+ input: item.arguments,
93
93
  providerOptions: item.providerMetadata,
94
94
  },
95
95
  ],
@@ -34,7 +34,7 @@ export const MODEL_RESPONSE = {
34
34
  callId: item.toolCallId,
35
35
  toolId: item.toolName,
36
36
  state: "completed",
37
- arguments: JSON.stringify(item.input),
37
+ arguments: item.input,
38
38
  providerMetadata: item.providerMetadata,
39
39
  });
40
40
  }
@@ -1 +1 @@
1
- {"version":3,"file":"stream.d.ts","sourceRoot":"","sources":["../../src/convert/stream.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AAC3E,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,kBAAkB,CAAC;AAGlE;;GAEG;AACH,wBAAuB,aAAa,CAClC,MAAM,EAAE,cAAc,CAAC,yBAAyB,CAAC,GAChD,aAAa,CAAC,wBAAwB,CAAC,CAgBzC;AAED;;GAEG;AACH,eAAO,MAAM,WAAW,EAAE,KAAK,CAC7B,wBAAwB,GAAG,IAAI,EAC/B,yBAAyB,CAoJ1B,CAAC"}
1
+ {"version":3,"file":"stream.d.ts","sourceRoot":"","sources":["../../src/convert/stream.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AAC3E,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,kBAAkB,CAAC;AAIlE;;GAEG;AACH,wBAAuB,aAAa,CAClC,MAAM,EAAE,cAAc,CAAC,yBAAyB,CAAC,GAChD,aAAa,CAAC,wBAAwB,CAAC,CAgBzC;AAED;;GAEG;AACH,eAAO,MAAM,WAAW,EAAE,KAAK,CAC7B,wBAAwB,GAAG,IAAI,EAC/B,yBAAyB,CAqJ1B,CAAC"}
@@ -1,3 +1,4 @@
1
+ import { COMPLETED, FAILED } from "@kernl-sdk/protocol";
1
2
  import { WARNING } from "./response";
2
3
  /**
3
4
  * Convert AI SDK stream to async iterable of kernl stream events.
@@ -90,8 +91,9 @@ export const STREAM_PART = {
90
91
  case "tool-call":
91
92
  return {
92
93
  kind: "tool-call",
93
- id: part.toolCallId,
94
- toolName: part.toolName,
94
+ callId: part.toolCallId,
95
+ toolId: part.toolName,
96
+ state: COMPLETED,
95
97
  arguments: part.input,
96
98
  providerMetadata: part.providerMetadata,
97
99
  };
@@ -101,7 +103,7 @@ export const STREAM_PART = {
101
103
  kind: "tool-result",
102
104
  callId: part.toolCallId,
103
105
  toolId: part.toolName,
104
- state: part.isError ? "failed" : "completed",
106
+ state: part.isError ? FAILED : COMPLETED,
105
107
  result: part.result,
106
108
  error: part.isError ? String(part.result) : null,
107
109
  providerMetadata: part.providerMetadata,
package/dist/index.d.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
  *