@outputai/llm 0.11.0 → 0.11.1-next.2223fa5.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.
Files changed (69) hide show
  1. package/package.json +2 -2
  2. package/src/agent.js +105 -83
  3. package/src/agent.spec.js +312 -225
  4. package/src/ai_provider.js +2 -2
  5. package/src/ai_sdk_options.js +93 -39
  6. package/src/ai_sdk_options.spec.js +314 -154
  7. package/src/consts.js +6 -0
  8. package/src/generate.js +81 -0
  9. package/src/generate.spec.js +466 -0
  10. package/src/index.d.ts +222 -242
  11. package/src/index.js +3 -4
  12. package/src/prompt/content.js +70 -0
  13. package/src/prompt/content.spec.js +208 -0
  14. package/src/prompt/{escape.js → interpolations.js} +11 -18
  15. package/src/prompt/interpolations.spec.js +109 -0
  16. package/src/prompt/loader.full.spec.js +293 -0
  17. package/src/prompt/loader.js +45 -39
  18. package/src/prompt/loader.spec.js +186 -289
  19. package/src/prompt/markup/attributes.js +52 -0
  20. package/src/prompt/markup/attributes.spec.js +132 -0
  21. package/src/prompt/markup/nodes.js +71 -0
  22. package/src/prompt/markup/nodes.spec.js +333 -0
  23. package/src/prompt/markup/tokenizer.js +3 -0
  24. package/src/prompt/markup/tokenizer.spec.js +149 -0
  25. package/src/prompt/markup/tokens.js +26 -0
  26. package/src/prompt/markup/tokens.spec.js +250 -0
  27. package/src/prompt/validations.js +98 -67
  28. package/src/prompt/validations.spec.js +204 -47
  29. package/src/{prompt/load_content.js → utils/file.js} +12 -15
  30. package/src/utils/file.spec.js +89 -0
  31. package/src/utils/models.js +15 -0
  32. package/src/utils/models.spec.js +119 -0
  33. package/src/utils/skills.js +74 -0
  34. package/src/utils/skills.spec.js +168 -0
  35. package/src/utils/sources.js +48 -0
  36. package/src/utils/sources.spec.js +122 -0
  37. package/src/utils/stream.js +22 -0
  38. package/src/utils/stream.spec.js +55 -0
  39. package/src/utils/tools.js +47 -0
  40. package/src/utils/tools.spec.js +167 -0
  41. package/src/utils/wrap.js +148 -0
  42. package/src/utils/wrap.spec.js +359 -0
  43. package/src/validations.js +149 -27
  44. package/src/validations.spec.js +451 -53
  45. package/src/ai_model.js +0 -60
  46. package/src/ai_model.spec.js +0 -259
  47. package/src/ai_sdk.js +0 -92
  48. package/src/ai_sdk.spec.js +0 -564
  49. package/src/prompt/block_options.js +0 -58
  50. package/src/prompt/block_options.spec.js +0 -71
  51. package/src/prompt/blocks.js +0 -47
  52. package/src/prompt/blocks.spec.js +0 -63
  53. package/src/prompt/escape.spec.js +0 -159
  54. package/src/prompt/load_content.spec.js +0 -83
  55. package/src/prompt/loader_validation.spec.js +0 -128
  56. package/src/prompt/parser.js +0 -16
  57. package/src/prompt/parser.spec.js +0 -186
  58. package/src/prompt/prepare_text.js +0 -27
  59. package/src/prompt/prepare_text.spec.js +0 -141
  60. package/src/prompt/skill.js +0 -128
  61. package/src/prompt/skill.spec.js +0 -172
  62. package/src/utils/message.js +0 -3
  63. package/src/utils/message.spec.js +0 -29
  64. package/src/utils/response_wrappers.js +0 -100
  65. package/src/utils/response_wrappers.spec.js +0 -240
  66. package/src/utils/source_extraction.js +0 -53
  67. package/src/utils/source_extraction.spec.js +0 -194
  68. package/src/utils/trace.js +0 -19
  69. package/src/utils/trace.spec.js +0 -112
@@ -0,0 +1,466 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ const aiFns = vi.hoisted( () => ( {
4
+ generateText: vi.fn(),
5
+ streamText: vi.fn(),
6
+ generateImage: vi.fn()
7
+ } ) );
8
+
9
+ const validations = vi.hoisted( () => ( {
10
+ parseGenerateTextArgs: vi.fn(),
11
+ parseGenerateTextWithStreamingArgs: vi.fn(),
12
+ parseStreamTextArgs: vi.fn(),
13
+ parseGenerateImageArgs: vi.fn()
14
+ } ) );
15
+
16
+ const toPromptFileArgs = ( { prompt, ...rest } ) => ( {
17
+ promptFile: prompt,
18
+ ...rest
19
+ } );
20
+
21
+ const promptMocks = vi.hoisted( () => ( {
22
+ loadPrompt: vi.fn()
23
+ } ) );
24
+
25
+ const skillMocks = vi.hoisted( () => ( {
26
+ loadSkills: vi.fn()
27
+ } ) );
28
+
29
+ const optionMocks = vi.hoisted( () => ( {
30
+ loadAiSdkTextOptions: vi.fn(),
31
+ loadAiSdkImageOptions: vi.fn()
32
+ } ) );
33
+
34
+ const wrapMocks = vi.hoisted( () => ( {
35
+ wrapGeneration: vi.fn(),
36
+ wrapStream: vi.fn(),
37
+ streamHooks: { onFinishHook: vi.fn(), onErrorHook: vi.fn() }
38
+ } ) );
39
+
40
+ const streamMocks = vi.hoisted( () => ( {
41
+ drainStream: vi.fn()
42
+ } ) );
43
+
44
+ vi.mock( 'ai', () => aiFns );
45
+
46
+ vi.mock( './validations.js', () => validations );
47
+
48
+ vi.mock( './prompt/loader.js', () => ( {
49
+ loadPrompt: ( ...args ) => promptMocks.loadPrompt( ...args )
50
+ } ) );
51
+
52
+ vi.mock( './utils/skills.js', () => ( {
53
+ loadSkills: ( ...args ) => skillMocks.loadSkills( ...args )
54
+ } ) );
55
+
56
+ vi.mock( './ai_sdk_options.js', () => ( {
57
+ loadAiSdkTextOptions: ( ...args ) => optionMocks.loadAiSdkTextOptions( ...args ),
58
+ loadAiSdkImageOptions: ( ...args ) => optionMocks.loadAiSdkImageOptions( ...args )
59
+ } ) );
60
+
61
+ vi.mock( './utils/wrap.js', () => ( {
62
+ wrapGeneration: ( ...args ) => wrapMocks.wrapGeneration( ...args ),
63
+ wrapStream: ( ...args ) => wrapMocks.wrapStream( ...args )
64
+ } ) );
65
+
66
+ vi.mock( './utils/stream.js', () => ( {
67
+ drainStream: ( ...args ) => streamMocks.drainStream( ...args )
68
+ } ) );
69
+
70
+ const importSut = async () => import( './generate.js' );
71
+
72
+ const loadedPrompt = {
73
+ name: 'test@v1',
74
+ config: { provider: 'openai', model: 'test-model' },
75
+ messages: [ { role: 'user', content: 'Hello' } ]
76
+ };
77
+
78
+ const loadedSkills = [ { name: 'writer', description: 'Writes', instructions: 'Do it.' } ];
79
+
80
+ const textOptions = {
81
+ model: 'MODEL',
82
+ messages: loadedPrompt.messages,
83
+ providerOptions: { test: true }
84
+ };
85
+
86
+ const textResponse = {
87
+ text: 'TEXT',
88
+ totalUsage: { inputTokens: 1, outputTokens: 2 },
89
+ finishReason: 'stop'
90
+ };
91
+
92
+ const streamResult = {
93
+ textStream: 'TEXT_STREAM',
94
+ fullStream: 'FULL_STREAM'
95
+ };
96
+
97
+ const imageOptions = {
98
+ model: 'IMAGE_MODEL',
99
+ prompt: {
100
+ text: 'Generate an image'
101
+ },
102
+ providerOptions: { openai: { quality: 'high' } }
103
+ };
104
+
105
+ const imageResponse = {
106
+ images: [ { mediaType: 'image/png', base64: 'aW1hZ2U=' } ],
107
+ usage: { inputTokens: 1, outputTokens: 2 }
108
+ };
109
+
110
+ describe( 'generate', () => {
111
+ beforeEach( () => {
112
+ aiFns.generateText.mockReset().mockResolvedValue( textResponse );
113
+ aiFns.streamText.mockReset().mockReturnValue( streamResult );
114
+ aiFns.generateImage.mockReset().mockResolvedValue( imageResponse );
115
+
116
+ validations.parseGenerateTextArgs.mockReset().mockImplementation( toPromptFileArgs );
117
+ validations.parseGenerateTextWithStreamingArgs.mockReset().mockImplementation( toPromptFileArgs );
118
+ validations.parseStreamTextArgs.mockReset().mockImplementation( toPromptFileArgs );
119
+ validations.parseGenerateImageArgs.mockReset().mockImplementation( toPromptFileArgs );
120
+
121
+ promptMocks.loadPrompt.mockReset().mockReturnValue( loadedPrompt );
122
+ skillMocks.loadSkills.mockReset().mockReturnValue( loadedSkills );
123
+
124
+ optionMocks.loadAiSdkTextOptions.mockReset().mockReturnValue( textOptions );
125
+ optionMocks.loadAiSdkImageOptions.mockReset().mockReturnValue( imageOptions );
126
+
127
+ wrapMocks.wrapGeneration.mockReset().mockImplementation( async ( { fn } ) => fn() );
128
+ wrapMocks.streamHooks = {
129
+ onFinishHook: vi.fn( async ( response, callback ) => callback?.( response ) ),
130
+ onErrorHook: vi.fn( ( event, callback ) => callback?.( event.error ) )
131
+ };
132
+ wrapMocks.wrapStream.mockReset().mockImplementation( ( { fn } ) => fn( wrapMocks.streamHooks ) );
133
+
134
+ streamMocks.drainStream.mockReset().mockResolvedValue( undefined );
135
+ } );
136
+
137
+ afterEach( async () => {
138
+ await vi.resetModules();
139
+ } );
140
+
141
+ describe( 'generateText', () => {
142
+ it( 'parses args, loads prompt skills, wraps generation, and calls AI SDK', async () => {
143
+ const { generateText } = await importSut();
144
+ const variables = { topic: 'testing' };
145
+ const tools = { userTool: true };
146
+ const output = { type: 'object' };
147
+ const toolChoice = 'required';
148
+ const stopWhen = { type: 'custom-stop' };
149
+ const abortSignal = new AbortController().signal;
150
+
151
+ const result = await generateText( {
152
+ prompt: 'test@v1',
153
+ variables,
154
+ promptDir: '/prompts',
155
+ tools,
156
+ output,
157
+ toolChoice,
158
+ stopWhen,
159
+ abortSignal
160
+ } );
161
+
162
+ expect( validations.parseGenerateTextArgs ).toHaveBeenCalledWith( {
163
+ prompt: 'test@v1',
164
+ variables,
165
+ promptDir: '/prompts',
166
+ tools,
167
+ output,
168
+ toolChoice,
169
+ stopWhen,
170
+ abortSignal
171
+ } );
172
+ expect( promptMocks.loadPrompt ).toHaveBeenCalledWith( 'test@v1', variables, '/prompts' );
173
+ expect( skillMocks.loadSkills ).toHaveBeenCalledWith( loadedPrompt );
174
+ expect( wrapMocks.wrapGeneration ).toHaveBeenCalledWith( {
175
+ name: 'generateText',
176
+ prompt: loadedPrompt,
177
+ fn: expect.any( Function )
178
+ } );
179
+ expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( {
180
+ prompt: loadedPrompt,
181
+ skills: loadedSkills,
182
+ tools,
183
+ output,
184
+ toolChoice,
185
+ stopWhen,
186
+ abortSignal
187
+ } );
188
+ expect( aiFns.generateText ).toHaveBeenCalledWith( textOptions );
189
+ expect( result ).toBe( textResponse );
190
+ } );
191
+
192
+ it( 'propagates parse errors before wrapping or calling AI SDK', async () => {
193
+ const validationError = new Error( 'Invalid args' );
194
+ validations.parseGenerateTextArgs.mockImplementationOnce( () => {
195
+ throw validationError;
196
+ } );
197
+ const { generateText } = await importSut();
198
+
199
+ await expect( generateText( { prompt: '' } ) ).rejects.toThrow( validationError );
200
+ expect( promptMocks.loadPrompt ).not.toHaveBeenCalled();
201
+ expect( wrapMocks.wrapGeneration ).not.toHaveBeenCalled();
202
+ expect( aiFns.generateText ).not.toHaveBeenCalled();
203
+ } );
204
+ } );
205
+
206
+ describe( 'generateTextWithStreaming', () => {
207
+ it( 'consumes the stream inside wrapGeneration and returns the completed response', async () => {
208
+ const { generateTextWithStreaming } = await importSut();
209
+ const variables = { topic: 'testing' };
210
+ const output = { summary: 'Structured result' };
211
+ const chunk = { type: 'text-delta', text: 'TEXT' };
212
+ const onChunk = vi.fn();
213
+ const stream = { output: Promise.resolve( output ) };
214
+ const response = { ...textResponse };
215
+ aiFns.streamText.mockImplementationOnce( options => {
216
+ options.onChunk( { chunk } );
217
+ options.onFinish( response );
218
+ return stream;
219
+ } );
220
+
221
+ const result = await generateTextWithStreaming( {
222
+ prompt: 'test@v1',
223
+ variables,
224
+ promptDir: '/prompts',
225
+ onChunk
226
+ } );
227
+
228
+ expect( validations.parseGenerateTextWithStreamingArgs ).toHaveBeenCalledWith( {
229
+ prompt: 'test@v1',
230
+ variables,
231
+ promptDir: '/prompts',
232
+ onChunk
233
+ } );
234
+ expect( wrapMocks.wrapGeneration ).toHaveBeenCalledWith( {
235
+ name: 'generateTextWithStreaming',
236
+ prompt: loadedPrompt,
237
+ fn: expect.any( Function )
238
+ } );
239
+ expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( {
240
+ prompt: loadedPrompt,
241
+ skills: loadedSkills
242
+ } );
243
+ expect( aiFns.streamText ).toHaveBeenCalledWith( {
244
+ ...textOptions,
245
+ onChunk,
246
+ onFinish: expect.any( Function ),
247
+ onError: expect.any( Function )
248
+ } );
249
+ expect( streamMocks.drainStream ).toHaveBeenCalledWith( stream, undefined );
250
+ expect( onChunk ).toHaveBeenCalledWith( { chunk } );
251
+ expect( result ).toEqual( { ...textResponse, output } );
252
+ } );
253
+
254
+ it( 'omits onChunk when the caller does not provide it', async () => {
255
+ const { generateTextWithStreaming } = await importSut();
256
+ const stream = { output: Promise.resolve( undefined ) };
257
+ aiFns.streamText.mockImplementationOnce( options => {
258
+ options.onFinish( { ...textResponse } );
259
+ return stream;
260
+ } );
261
+
262
+ await generateTextWithStreaming( { prompt: 'test@v1' } );
263
+ const callOptions = aiFns.streamText.mock.calls[0][0];
264
+
265
+ expect( callOptions ).not.toHaveProperty( 'onChunk' );
266
+ } );
267
+
268
+ it( 'rejects with the abort reason', async () => {
269
+ const abortController = new AbortController();
270
+ const abortReason = new Error( 'Cancelled by caller' );
271
+ const stream = { output: Promise.resolve( undefined ) };
272
+ abortController.abort( abortReason );
273
+ streamMocks.drainStream.mockRejectedValueOnce( abortReason );
274
+ aiFns.streamText.mockReturnValueOnce( stream );
275
+ const { generateTextWithStreaming } = await importSut();
276
+
277
+ await expect( generateTextWithStreaming( {
278
+ prompt: 'test@v1',
279
+ abortSignal: abortController.signal
280
+ } ) ).rejects.toBe( abortReason );
281
+
282
+ expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( {
283
+ prompt: loadedPrompt,
284
+ skills: loadedSkills,
285
+ abortSignal: abortController.signal
286
+ } );
287
+ expect( streamMocks.drainStream ).toHaveBeenCalledWith( stream, abortController.signal );
288
+ } );
289
+ } );
290
+
291
+ describe( 'streamText', () => {
292
+ it( 'parses args, loads prompt skills, wraps the stream, and calls AI SDK', async () => {
293
+ const { streamText } = await importSut();
294
+ const variables = { topic: 'testing' };
295
+ const onFinish = vi.fn();
296
+ const onChunk = vi.fn();
297
+ const tools = { userTool: true };
298
+
299
+ const result = streamText( {
300
+ prompt: 'test@v1',
301
+ variables,
302
+ promptDir: '/prompts',
303
+ onFinish,
304
+ onChunk,
305
+ tools
306
+ } );
307
+
308
+ expect( validations.parseStreamTextArgs ).toHaveBeenCalledWith( {
309
+ prompt: 'test@v1',
310
+ variables,
311
+ promptDir: '/prompts',
312
+ onFinish,
313
+ onChunk,
314
+ tools
315
+ } );
316
+ expect( promptMocks.loadPrompt ).toHaveBeenCalledWith( 'test@v1', variables, '/prompts' );
317
+ expect( skillMocks.loadSkills ).toHaveBeenCalledWith( loadedPrompt );
318
+ expect( wrapMocks.wrapStream ).toHaveBeenCalledWith( {
319
+ name: 'streamText',
320
+ prompt: loadedPrompt,
321
+ fn: expect.any( Function )
322
+ } );
323
+ expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( {
324
+ prompt: loadedPrompt,
325
+ skills: loadedSkills,
326
+ tools
327
+ } );
328
+ expect( aiFns.streamText ).toHaveBeenCalledWith( {
329
+ ...textOptions,
330
+ onChunk,
331
+ onFinish: expect.any( Function ),
332
+ onError: expect.any( Function )
333
+ } );
334
+ const callOptions = aiFns.streamText.mock.calls[0][0];
335
+ await callOptions.onFinish( textResponse );
336
+ expect( wrapMocks.streamHooks.onFinishHook ).toHaveBeenCalledWith( textResponse, onFinish );
337
+ expect( onFinish ).toHaveBeenCalledWith( textResponse );
338
+ expect( result ).toBe( streamResult );
339
+ } );
340
+
341
+ it( 'omits onChunk when the caller does not provide it', async () => {
342
+ const { streamText } = await importSut();
343
+
344
+ streamText( { prompt: 'test@v1' } );
345
+ const callOptions = aiFns.streamText.mock.calls[0][0];
346
+
347
+ expect( callOptions ).not.toHaveProperty( 'onChunk' );
348
+ } );
349
+
350
+ it( 'lets caller stopWhen reach the options loader', async () => {
351
+ const { streamText } = await importSut();
352
+ const stopWhen = { type: 'custom-stop' };
353
+
354
+ streamText( { prompt: 'test@v1', stopWhen } );
355
+
356
+ expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( {
357
+ prompt: loadedPrompt,
358
+ skills: loadedSkills,
359
+ stopWhen
360
+ } );
361
+ expect( aiFns.streamText ).toHaveBeenCalledWith( expect.objectContaining( textOptions ) );
362
+ } );
363
+
364
+ it( 'forwards stream onError through wrapStream with the mapped event payload', async () => {
365
+ const { streamText } = await importSut();
366
+ const onError = vi.fn();
367
+ const error = new Error( 'Stream failed' );
368
+ const mappedError = new Error( 'Mapped stream failed' );
369
+
370
+ streamText( { prompt: 'test@v1', onError } );
371
+ const callOptions = aiFns.streamText.mock.calls[0][0];
372
+ callOptions.onError( { error, extra: true } );
373
+
374
+ expect( wrapMocks.streamHooks.onErrorHook ).toHaveBeenCalledWith(
375
+ { error, extra: true },
376
+ expect.any( Function )
377
+ );
378
+ wrapMocks.streamHooks.onErrorHook.mock.calls[0][1]( mappedError );
379
+ expect( onError ).toHaveBeenCalledWith( { error: mappedError, extra: true } );
380
+ } );
381
+
382
+ it( 'propagates validation errors before loading or wrapping', async () => {
383
+ const validationError = new Error( 'Invalid args' );
384
+ validations.parseStreamTextArgs.mockImplementationOnce( () => {
385
+ throw validationError;
386
+ } );
387
+ const { streamText } = await importSut();
388
+
389
+ expect( () => streamText( { prompt: '' } ) ).toThrow( validationError );
390
+ expect( promptMocks.loadPrompt ).not.toHaveBeenCalled();
391
+ expect( skillMocks.loadSkills ).not.toHaveBeenCalled();
392
+ expect( wrapMocks.wrapStream ).not.toHaveBeenCalled();
393
+ expect( aiFns.streamText ).not.toHaveBeenCalled();
394
+ } );
395
+ } );
396
+
397
+ describe( 'generateImage', () => {
398
+ it( 'parses args, loads prompt, wraps generation, and calls AI SDK', async () => {
399
+ const { generateImage } = await importSut();
400
+ const variables = { scene: 'race cars' };
401
+ const images = [ Buffer.from( 'image-bytes' ) ];
402
+ const mask = Buffer.from( 'mask-bytes' );
403
+ const abortSignal = AbortSignal.abort();
404
+
405
+ const result = await generateImage( {
406
+ prompt: 'image@v1',
407
+ variables,
408
+ promptDir: '/prompts',
409
+ images,
410
+ mask,
411
+ abortSignal
412
+ } );
413
+
414
+ expect( validations.parseGenerateImageArgs ).toHaveBeenCalledWith( {
415
+ prompt: 'image@v1',
416
+ variables,
417
+ promptDir: '/prompts',
418
+ images,
419
+ mask,
420
+ abortSignal
421
+ } );
422
+ expect( promptMocks.loadPrompt ).toHaveBeenCalledWith( 'image@v1', variables, '/prompts' );
423
+ expect( skillMocks.loadSkills ).not.toHaveBeenCalled();
424
+ expect( wrapMocks.wrapGeneration ).toHaveBeenCalledWith( {
425
+ name: 'generateImage',
426
+ prompt: loadedPrompt,
427
+ fn: expect.any( Function )
428
+ } );
429
+ expect( optionMocks.loadAiSdkImageOptions ).toHaveBeenCalledWith( {
430
+ prompt: loadedPrompt,
431
+ images,
432
+ mask,
433
+ abortSignal
434
+ } );
435
+ expect( aiFns.generateImage ).toHaveBeenCalledWith( imageOptions );
436
+ expect( result ).toBe( imageResponse );
437
+ } );
438
+
439
+ it( 'supports text-to-image calls without images or mask', async () => {
440
+ const { generateImage } = await importSut();
441
+
442
+ await generateImage( { prompt: 'image@v1' } );
443
+
444
+ expect( validations.parseGenerateImageArgs ).toHaveBeenCalledWith( {
445
+ prompt: 'image@v1'
446
+ } );
447
+ expect( optionMocks.loadAiSdkImageOptions ).toHaveBeenCalledWith( {
448
+ prompt: loadedPrompt
449
+ } );
450
+ expect( aiFns.generateImage ).toHaveBeenCalledWith( imageOptions );
451
+ } );
452
+
453
+ it( 'propagates parse errors before loading or wrapping', async () => {
454
+ const validationError = new Error( 'Invalid image args' );
455
+ validations.parseGenerateImageArgs.mockImplementationOnce( () => {
456
+ throw validationError;
457
+ } );
458
+ const { generateImage } = await importSut();
459
+
460
+ await expect( generateImage( { prompt: '' } ) ).rejects.toThrow( validationError );
461
+ expect( promptMocks.loadPrompt ).not.toHaveBeenCalled();
462
+ expect( wrapMocks.wrapGeneration ).not.toHaveBeenCalled();
463
+ expect( aiFns.generateImage ).not.toHaveBeenCalled();
464
+ } );
465
+ } );
466
+ } );