@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
@@ -1,564 +0,0 @@
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
- stepCountIs: vi.fn( count => ( { type: 'step-count', count } ) )
8
- } ) );
9
-
10
- const validators = vi.hoisted( () => ( {
11
- validateGenerateTextArgs: vi.fn(),
12
- validateStreamTextArgs: vi.fn(),
13
- validateGenerateImageArgs: vi.fn()
14
- } ) );
15
-
16
- const promptMocks = vi.hoisted( () => ( {
17
- loadPrompt: vi.fn(),
18
- prepareTextPrompt: vi.fn()
19
- } ) );
20
-
21
- const optionMocks = vi.hoisted( () => ( {
22
- loadAiSdkTextOptions: vi.fn(),
23
- loadAiSdkImageOptions: vi.fn()
24
- } ) );
25
-
26
- const traceMocks = vi.hoisted( () => ( {
27
- startTrace: vi.fn(),
28
- endTraceWithError: vi.fn()
29
- } ) );
30
-
31
- const wrapMocks = vi.hoisted( () => ( {
32
- wrapTextResponse: vi.fn(),
33
- wrapStreamOnFinishResponse: vi.fn(),
34
- wrapImageResponse: vi.fn()
35
- } ) );
36
-
37
- const errorMocks = vi.hoisted( () => ( {
38
- mapAiError: vi.fn( error => error )
39
- } ) );
40
-
41
- vi.mock( 'ai', () => aiFns );
42
-
43
- vi.mock( './validations.js', () => validators );
44
-
45
- vi.mock( './prompt/loader.js', () => ( {
46
- loadPrompt: ( ...args ) => promptMocks.loadPrompt( ...args )
47
- } ) );
48
-
49
- vi.mock( './prompt/prepare_text.js', () => ( {
50
- prepareTextPrompt: ( ...args ) => promptMocks.prepareTextPrompt( ...args )
51
- } ) );
52
-
53
- vi.mock( './ai_sdk_options.js', () => ( {
54
- loadAiSdkTextOptions: ( ...args ) => optionMocks.loadAiSdkTextOptions( ...args ),
55
- loadAiSdkImageOptions: ( ...args ) => optionMocks.loadAiSdkImageOptions( ...args )
56
- } ) );
57
-
58
- vi.mock( './utils/trace.js', () => ( {
59
- startTrace: ( ...args ) => traceMocks.startTrace( ...args ),
60
- endTraceWithError: ( ...args ) => traceMocks.endTraceWithError( ...args )
61
- } ) );
62
-
63
- vi.mock( './utils/response_wrappers.js', () => ( {
64
- wrapTextResponse: ( ...args ) => wrapMocks.wrapTextResponse( ...args ),
65
- wrapStreamOnFinishResponse: ( ...args ) => wrapMocks.wrapStreamOnFinishResponse( ...args ),
66
- wrapImageResponse: ( ...args ) => wrapMocks.wrapImageResponse( ...args )
67
- } ) );
68
-
69
- vi.mock( './utils/error_handler.js', () => ( {
70
- mapAiError: ( ...args ) => errorMocks.mapAiError( ...args )
71
- } ) );
72
-
73
- const importSut = async () => import( './ai_sdk.js' );
74
-
75
- const loadedPrompt = {
76
- name: 'test@v1',
77
- config: { provider: 'openai', model: 'test-model' },
78
- messages: [ { role: 'user', content: 'Hello' } ]
79
- };
80
-
81
- const textOptions = {
82
- model: 'MODEL',
83
- messages: loadedPrompt.messages,
84
- providerOptions: { test: true }
85
- };
86
-
87
- const textResponse = {
88
- text: 'TEXT',
89
- totalUsage: { inputTokens: 1, outputTokens: 2 },
90
- finishReason: 'stop'
91
- };
92
-
93
- const streamResult = {
94
- textStream: 'TEXT_STREAM',
95
- fullStream: 'FULL_STREAM'
96
- };
97
-
98
- const imageOptions = {
99
- model: 'IMAGE_MODEL',
100
- prompt: {
101
- text: 'Generate an image'
102
- },
103
- providerOptions: { openai: { quality: 'high' } }
104
- };
105
-
106
- const imageResponse = {
107
- images: [ { mediaType: 'image/png', base64: 'aW1hZ2U=' } ],
108
- usage: { inputTokens: 1, outputTokens: 2 }
109
- };
110
-
111
- describe( 'ai_sdk', () => {
112
- beforeEach( () => {
113
- aiFns.generateText.mockReset().mockResolvedValue( textResponse );
114
- aiFns.streamText.mockReset().mockReturnValue( streamResult );
115
- aiFns.generateImage.mockReset().mockResolvedValue( imageResponse );
116
- aiFns.stepCountIs.mockReset().mockImplementation( count => ( { type: 'step-count', count } ) );
117
-
118
- validators.validateGenerateTextArgs.mockReset();
119
- validators.validateStreamTextArgs.mockReset();
120
- validators.validateGenerateImageArgs.mockReset();
121
-
122
- promptMocks.loadPrompt.mockReset().mockReturnValue( loadedPrompt );
123
- promptMocks.prepareTextPrompt.mockReset().mockReturnValue( {
124
- loadedPrompt,
125
- tools: null
126
- } );
127
-
128
- optionMocks.loadAiSdkTextOptions.mockReset().mockReturnValue( textOptions );
129
- optionMocks.loadAiSdkImageOptions.mockReset().mockReturnValue( imageOptions );
130
-
131
- traceMocks.startTrace.mockReset().mockReturnValue( 'trace-id' );
132
- traceMocks.endTraceWithError.mockReset();
133
-
134
- wrapMocks.wrapTextResponse.mockReset().mockResolvedValue( { wrapped: textResponse } );
135
- wrapMocks.wrapStreamOnFinishResponse.mockReset().mockReturnValue( {
136
- onFinish: vi.fn()
137
- } );
138
- wrapMocks.wrapImageResponse.mockReset().mockResolvedValue( { wrapped: imageResponse } );
139
-
140
- errorMocks.mapAiError.mockReset().mockImplementation( error => error );
141
- } );
142
-
143
- afterEach( async () => {
144
- await vi.resetModules();
145
- } );
146
-
147
- describe( 'generateText', () => {
148
- it( 'prepares, validates, traces, calls AI SDK, and wraps the response', async () => {
149
- const { generateText } = await importSut();
150
- const variables = { topic: 'testing' };
151
- const tools = { calculator: { description: 'Calculator' } };
152
- const skills = [ { name: 'style', description: 'Style', instructions: '# Style' } ];
153
-
154
- promptMocks.prepareTextPrompt.mockReturnValueOnce( {
155
- loadedPrompt,
156
- tools
157
- } );
158
-
159
- const result = await generateText( {
160
- prompt: 'test@v1',
161
- variables,
162
- promptDir: '/prompts',
163
- skills,
164
- maxSteps: 4,
165
- tools: { userTool: true },
166
- temperature: 0.2
167
- } );
168
-
169
- expect( promptMocks.prepareTextPrompt ).toHaveBeenCalledWith( {
170
- prompt: 'test@v1',
171
- variables,
172
- promptDir: '/prompts',
173
- skills,
174
- tools: { userTool: true }
175
- } );
176
- expect( validators.validateGenerateTextArgs ).toHaveBeenCalledWith( {
177
- prompt: 'test@v1',
178
- variables,
179
- promptDir: '/prompts',
180
- skills,
181
- maxSteps: 4
182
- } );
183
- expect( traceMocks.startTrace ).toHaveBeenCalledWith( {
184
- name: 'generateText',
185
- prompt: 'test@v1',
186
- variables,
187
- loadedPrompt
188
- } );
189
- expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( loadedPrompt );
190
- expect( aiFns.stepCountIs ).toHaveBeenCalledWith( 4 );
191
- expect( aiFns.generateText ).toHaveBeenCalledWith( {
192
- ...textOptions,
193
- allowSystemInMessages: true,
194
- maxRetries: 0,
195
- tools,
196
- temperature: 0.2,
197
- stopWhen: { type: 'step-count', count: 4 }
198
- } );
199
- expect( wrapMocks.wrapTextResponse ).toHaveBeenCalledWith( {
200
- traceId: 'trace-id',
201
- providerId: 'openai',
202
- modelId: 'test-model',
203
- response: textResponse
204
- } );
205
- expect( result ).toEqual( { wrapped: textResponse } );
206
- } );
207
-
208
- it( 'uses resolved dynamic skills', async () => {
209
- const { generateText } = await importSut();
210
- const variables = { topic: 'testing' };
211
- const resolvedSkills = [ { name: 'dynamic', description: 'Dynamic', instructions: '# Dynamic' } ];
212
- const skills = vi.fn().mockResolvedValue( resolvedSkills );
213
-
214
- await generateText( { prompt: 'test@v1', variables, skills } );
215
-
216
- expect( skills ).toHaveBeenCalledWith( variables );
217
- expect( promptMocks.prepareTextPrompt ).toHaveBeenCalledWith( expect.objectContaining( {
218
- skills: resolvedSkills
219
- } ) );
220
- } );
221
-
222
- it( 'omits tools and stopWhen when no tools are prepared', async () => {
223
- const { generateText } = await importSut();
224
-
225
- await generateText( { prompt: 'test@v1' } );
226
-
227
- expect( aiFns.stepCountIs ).not.toHaveBeenCalled();
228
- expect( aiFns.generateText ).toHaveBeenCalledWith( {
229
- ...textOptions,
230
- allowSystemInMessages: true,
231
- maxRetries: 0
232
- } );
233
- } );
234
-
235
- it( 'preserves caller-provided stopWhen when tools are prepared', async () => {
236
- const { generateText } = await importSut();
237
- const stopWhen = { type: 'custom-stop' };
238
- promptMocks.prepareTextPrompt.mockReturnValueOnce( {
239
- loadedPrompt,
240
- tools: { load_skill: { description: 'Load skill' } }
241
- } );
242
-
243
- await generateText( { prompt: 'test@v1', stopWhen } );
244
-
245
- expect( aiFns.stepCountIs ).not.toHaveBeenCalled();
246
- expect( aiFns.generateText ).toHaveBeenCalledWith( expect.objectContaining( {
247
- stopWhen
248
- } ) );
249
- } );
250
-
251
- it( 'propagates validation errors before tracing or calling AI SDK', async () => {
252
- const validationError = new Error( 'Invalid args' );
253
- validators.validateGenerateTextArgs.mockImplementationOnce( () => {
254
- throw validationError;
255
- } );
256
- const { generateText } = await importSut();
257
-
258
- await expect( generateText( { prompt: '' } ) ).rejects.toThrow( validationError );
259
- expect( traceMocks.startTrace ).not.toHaveBeenCalled();
260
- expect( aiFns.generateText ).not.toHaveBeenCalled();
261
- } );
262
-
263
- it( 'traces and rethrows AI SDK errors', async () => {
264
- const error = new Error( 'Provider failed' );
265
- const mappedError = new Error( 'Mapped provider failed' );
266
- aiFns.generateText.mockRejectedValueOnce( error );
267
- errorMocks.mapAiError.mockReturnValueOnce( mappedError );
268
- const { generateText } = await importSut();
269
-
270
- await expect( generateText( { prompt: 'test@v1' } ) ).rejects.toThrow( mappedError );
271
- expect( errorMocks.mapAiError ).toHaveBeenCalledWith( error );
272
- expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( {
273
- traceId: 'trace-id',
274
- error: mappedError
275
- } );
276
- } );
277
- } );
278
-
279
- describe( 'streamText', () => {
280
- it( 'prepares, validates, traces, calls AI SDK, and returns the stream result', async () => {
281
- const { streamText } = await importSut();
282
- const variables = { topic: 'testing' };
283
- const onFinish = vi.fn();
284
- const tools = { calculator: { description: 'Calculator' } };
285
- const skills = [ { name: 'style', description: 'Style', instructions: '# Style' } ];
286
-
287
- promptMocks.prepareTextPrompt.mockReturnValueOnce( {
288
- loadedPrompt,
289
- tools
290
- } );
291
-
292
- const result = streamText( {
293
- prompt: 'test@v1',
294
- variables,
295
- promptDir: '/prompts',
296
- skills,
297
- maxSteps: 4,
298
- onFinish,
299
- tools: { userTool: true },
300
- temperature: 0.2
301
- } );
302
-
303
- expect( validators.validateStreamTextArgs ).toHaveBeenCalledWith( {
304
- prompt: 'test@v1',
305
- variables,
306
- promptDir: '/prompts',
307
- skills,
308
- maxSteps: 4
309
- } );
310
- expect( promptMocks.prepareTextPrompt ).toHaveBeenCalledWith( {
311
- prompt: 'test@v1',
312
- variables,
313
- promptDir: '/prompts',
314
- skills,
315
- tools: { userTool: true }
316
- } );
317
- expect( traceMocks.startTrace ).toHaveBeenCalledWith( {
318
- name: 'streamText',
319
- prompt: 'test@v1',
320
- variables,
321
- loadedPrompt
322
- } );
323
- expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( loadedPrompt );
324
- expect( wrapMocks.wrapStreamOnFinishResponse ).toHaveBeenCalledWith( {
325
- traceId: 'trace-id',
326
- providerId: 'openai',
327
- modelId: 'test-model',
328
- onFinish
329
- } );
330
- expect( aiFns.stepCountIs ).toHaveBeenCalledWith( 4 );
331
- expect( aiFns.streamText ).toHaveBeenCalledWith( {
332
- ...textOptions,
333
- allowSystemInMessages: true,
334
- maxRetries: 0,
335
- tools,
336
- temperature: 0.2,
337
- stopWhen: { type: 'step-count', count: 4 },
338
- onFinish: expect.any( Function ),
339
- onError: expect.any( Function )
340
- } );
341
- expect( result ).toBe( streamResult );
342
- } );
343
-
344
- it( 'uses resolved dynamic skills', async () => {
345
- const { streamText } = await importSut();
346
- const variables = { topic: 'testing' };
347
- const resolvedSkills = [ { name: 'dynamic', description: 'Dynamic', instructions: '# Dynamic' } ];
348
- const skills = vi.fn().mockReturnValue( resolvedSkills );
349
-
350
- streamText( { prompt: 'test@v1', variables, skills } );
351
-
352
- expect( skills ).toHaveBeenCalledWith( variables );
353
- expect( promptMocks.prepareTextPrompt ).toHaveBeenCalledWith( expect.objectContaining( {
354
- skills: resolvedSkills
355
- } ) );
356
- } );
357
-
358
- it( 'throws when dynamic skills resolve asynchronously', async () => {
359
- const { streamText } = await importSut();
360
- const variables = { topic: 'testing' };
361
- const skills = vi.fn().mockResolvedValue( [
362
- { name: 'dynamic', description: 'Dynamic', instructions: '# Dynamic' }
363
- ] );
364
-
365
- expect( () => streamText( { prompt: 'test@v1', variables, skills } ) )
366
- .toThrow( 'streamText() skills must be synchronous' );
367
-
368
- expect( skills ).toHaveBeenCalledWith( variables );
369
- expect( promptMocks.prepareTextPrompt ).not.toHaveBeenCalled();
370
- expect( aiFns.streamText ).not.toHaveBeenCalled();
371
- } );
372
-
373
- it( 'omits tools and stopWhen when no tools are prepared', async () => {
374
- const { streamText } = await importSut();
375
-
376
- streamText( { prompt: 'test@v1' } );
377
-
378
- expect( aiFns.stepCountIs ).not.toHaveBeenCalled();
379
- expect( aiFns.streamText ).toHaveBeenCalledWith( {
380
- ...textOptions,
381
- allowSystemInMessages: true,
382
- maxRetries: 0,
383
- onFinish: expect.any( Function ),
384
- onError: expect.any( Function )
385
- } );
386
- } );
387
-
388
- it( 'preserves caller-provided stopWhen when tools are prepared', async () => {
389
- const { streamText } = await importSut();
390
- const stopWhen = { type: 'custom-stop' };
391
- promptMocks.prepareTextPrompt.mockReturnValueOnce( {
392
- loadedPrompt,
393
- tools: { load_skill: { description: 'Load skill' } }
394
- } );
395
-
396
- streamText( { prompt: 'test@v1', stopWhen } );
397
-
398
- expect( aiFns.stepCountIs ).not.toHaveBeenCalled();
399
- expect( aiFns.streamText ).toHaveBeenCalledWith( expect.objectContaining( {
400
- stopWhen
401
- } ) );
402
- } );
403
-
404
- it( 'traces stream onError events and calls the user callback', async () => {
405
- const { streamText } = await importSut();
406
- const onError = vi.fn();
407
- const error = new Error( 'Stream failed' );
408
- const mappedError = new Error( 'Mapped stream failed' );
409
- errorMocks.mapAiError.mockReturnValueOnce( mappedError );
410
-
411
- streamText( { prompt: 'test@v1', onError } );
412
- const callOptions = aiFns.streamText.mock.calls[0][0];
413
- callOptions.onError( { error } );
414
-
415
- expect( errorMocks.mapAiError ).toHaveBeenCalledWith( error );
416
- expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( {
417
- traceId: 'trace-id',
418
- error: mappedError
419
- } );
420
- expect( onError ).toHaveBeenCalledWith( { error: mappedError } );
421
- } );
422
-
423
- it( 'does not pass the raw onFinish or onError callbacks to AI SDK', async () => {
424
- const { streamText } = await importSut();
425
- const onFinish = vi.fn();
426
- const onError = vi.fn();
427
-
428
- streamText( { prompt: 'test@v1', onFinish, onError } );
429
- const callOptions = aiFns.streamText.mock.calls[0][0];
430
-
431
- expect( callOptions.onFinish ).not.toBe( onFinish );
432
- expect( callOptions.onError ).not.toBe( onError );
433
- } );
434
-
435
- it( 'propagates validation errors before loading or tracing', async () => {
436
- const validationError = new Error( 'Invalid args' );
437
- validators.validateStreamTextArgs.mockImplementationOnce( () => {
438
- throw validationError;
439
- } );
440
- const { streamText } = await importSut();
441
-
442
- expect( () => streamText( { prompt: '' } ) ).toThrow( validationError );
443
- expect( promptMocks.prepareTextPrompt ).not.toHaveBeenCalled();
444
- expect( traceMocks.startTrace ).not.toHaveBeenCalled();
445
- expect( aiFns.streamText ).not.toHaveBeenCalled();
446
- } );
447
-
448
- it( 'traces and rethrows synchronous AI SDK errors', async () => {
449
- const error = new Error( 'Invalid model' );
450
- const mappedError = new Error( 'Mapped invalid model' );
451
- aiFns.streamText.mockImplementationOnce( () => {
452
- throw error;
453
- } );
454
- errorMocks.mapAiError.mockReturnValueOnce( mappedError );
455
- const { streamText } = await importSut();
456
-
457
- expect( () => streamText( { prompt: 'test@v1' } ) ).toThrow( mappedError );
458
- expect( errorMocks.mapAiError ).toHaveBeenCalledWith( error );
459
- expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( {
460
- traceId: 'trace-id',
461
- error: mappedError
462
- } );
463
- } );
464
- } );
465
-
466
- describe( 'generateImage', () => {
467
- it( 'validates, loads prompt, traces, calls AI SDK, and wraps the response', async () => {
468
- const { generateImage } = await importSut();
469
- const variables = { scene: 'race cars' };
470
- const images = [ Buffer.from( 'image-bytes' ) ];
471
- const mask = Buffer.from( 'mask-bytes' );
472
-
473
- const result = await generateImage( {
474
- prompt: 'image@v1',
475
- variables,
476
- promptDir: '/prompts',
477
- images,
478
- mask,
479
- n: 2,
480
- providerOptions: { openai: { background: 'transparent' } }
481
- } );
482
-
483
- expect( validators.validateGenerateImageArgs ).toHaveBeenCalledWith( {
484
- prompt: 'image@v1',
485
- variables,
486
- promptDir: '/prompts',
487
- images,
488
- mask
489
- } );
490
- expect( promptMocks.loadPrompt ).toHaveBeenCalledWith( 'image@v1', variables, '/prompts' );
491
- expect( traceMocks.startTrace ).toHaveBeenCalledWith( {
492
- name: 'generateImage',
493
- prompt: 'image@v1',
494
- variables,
495
- loadedPrompt
496
- } );
497
- expect( optionMocks.loadAiSdkImageOptions ).toHaveBeenCalledWith( {
498
- prompt: loadedPrompt,
499
- images,
500
- mask
501
- } );
502
- expect( aiFns.generateImage ).toHaveBeenCalledWith( {
503
- ...imageOptions,
504
- maxRetries: 0,
505
- n: 2,
506
- providerOptions: { openai: { background: 'transparent' } }
507
- } );
508
- expect( wrapMocks.wrapImageResponse ).toHaveBeenCalledWith( {
509
- traceId: 'trace-id',
510
- providerId: 'openai',
511
- modelId: 'test-model',
512
- response: imageResponse
513
- } );
514
- expect( result ).toEqual( { wrapped: imageResponse } );
515
- } );
516
-
517
- it( 'supports text-to-image calls without images or mask', async () => {
518
- const { generateImage } = await importSut();
519
-
520
- await generateImage( { prompt: 'image@v1' } );
521
-
522
- expect( validators.validateGenerateImageArgs ).toHaveBeenCalledWith( {
523
- prompt: 'image@v1',
524
- variables: undefined,
525
- promptDir: undefined,
526
- images: undefined,
527
- mask: undefined
528
- } );
529
- expect( optionMocks.loadAiSdkImageOptions ).toHaveBeenCalledWith( {
530
- prompt: loadedPrompt,
531
- images: undefined,
532
- mask: undefined
533
- } );
534
- } );
535
-
536
- it( 'propagates validation errors before loading or tracing', async () => {
537
- const validationError = new Error( 'Invalid image args' );
538
- validators.validateGenerateImageArgs.mockImplementationOnce( () => {
539
- throw validationError;
540
- } );
541
- const { generateImage } = await importSut();
542
-
543
- await expect( generateImage( { prompt: '' } ) ).rejects.toThrow( validationError );
544
- expect( promptMocks.loadPrompt ).not.toHaveBeenCalled();
545
- expect( traceMocks.startTrace ).not.toHaveBeenCalled();
546
- expect( aiFns.generateImage ).not.toHaveBeenCalled();
547
- } );
548
-
549
- it( 'traces and rethrows AI SDK errors', async () => {
550
- const error = new Error( 'Image provider failed' );
551
- const mappedError = new Error( 'Mapped image provider failed' );
552
- aiFns.generateImage.mockRejectedValueOnce( error );
553
- errorMocks.mapAiError.mockReturnValueOnce( mappedError );
554
- const { generateImage } = await importSut();
555
-
556
- await expect( generateImage( { prompt: 'image@v1' } ) ).rejects.toThrow( mappedError );
557
- expect( errorMocks.mapAiError ).toHaveBeenCalledWith( error );
558
- expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( {
559
- traceId: 'trace-id',
560
- error: mappedError
561
- } );
562
- } );
563
- } );
564
- } );
@@ -1,58 +0,0 @@
1
- import { FatalError, z } from '@outputai/core';
2
-
3
- /** Shallow-merge two providerOptions objects, combining keys within each provider namespace. */
4
- const mergeProviderOptions = ( base = {}, extra = {} ) => {
5
- const merged = { ...base };
6
- for ( const [ namespace, options ] of Object.entries( extra ) ) {
7
- merged[namespace] = { ...merged[namespace], ...options };
8
- }
9
- return merged;
10
- };
11
-
12
- /** Merge the named `messageOptions` sets referenced by a block's `options` attribute. */
13
- const resolveOptions = ( value, { name, config } ) => {
14
- const sets = config.messageOptions ?? {};
15
- return value.trim().split( /\s+/ ).reduce( ( acc, setName ) => {
16
- if ( !sets[setName] ) {
17
- throw new FatalError( `Prompt "${name}" references unknown messageOptions set "${setName}"` );
18
- }
19
- return mergeProviderOptions( acc, sets[setName] );
20
- }, {} );
21
- };
22
-
23
- /**
24
- * Registry of supported block attributes. Each entry declares how the attribute is validated
25
- * (`schema`) and how it contributes to a message's per-message `providerOptions` (`resolve`).
26
- * Add an entry to support a new block option — validation ({@link attributesSchema}) and
27
- * resolution ({@link resolveMessageProviderOptions}) both derive from this table.
28
- */
29
- const BLOCK_OPTIONS = {
30
- options: {
31
- schema: z.string().min( 1 ),
32
- resolve: resolveOptions
33
- }
34
- };
35
-
36
- /** Zod schema for a block's `attributes` object, derived from the option registry. */
37
- export const attributesSchema = z.object(
38
- Object.fromEntries(
39
- Object.entries( BLOCK_OPTIONS ).map( ( [ name, def ] ) => [ name, def.schema.optional() ] )
40
- )
41
- ).strict();
42
-
43
- /**
44
- * Resolve each message's authoring `attributes` into AI SDK per-message `providerOptions`,
45
- * returning clean messages with the `attributes` helper stripped.
46
- *
47
- * @param {object} prompt - Loaded prompt object (`{ name, config, messages }`)
48
- * @returns {Array<object>} Messages with resolved `providerOptions`
49
- */
50
- export const resolveMessageProviderOptions = ( { name, config, messages } ) =>
51
- messages.map( ( { attributes, providerOptions, ...message } ) => {
52
- const resolved = Object.entries( attributes ?? {} ).reduce( ( acc, [ key, value ] ) => {
53
- const option = BLOCK_OPTIONS[key];
54
- return option ? mergeProviderOptions( acc, option.resolve( value, { name, config } ) ) : acc;
55
- }, providerOptions ?? {} );
56
-
57
- return Object.keys( resolved ).length > 0 ? { ...message, providerOptions: resolved } : message;
58
- } );
@@ -1,71 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { FatalError } from '@outputai/core';
3
- import { attributesSchema, resolveMessageProviderOptions } from './block_options.js';
4
-
5
- const textPrompt = ( { config = {}, messages } ) => ( {
6
- name: 'test@v1',
7
- config: { provider: 'anthropic', model: 'claude-sonnet-4-5', ...config },
8
- messages
9
- } );
10
-
11
- describe( 'attributesSchema', () => {
12
- it( 'accepts the options attribute', () => {
13
- expect( attributesSchema.safeParse( { options: 'cached' } ).success ).toBe( true );
14
- expect( attributesSchema.safeParse( { options: 'cached fast' } ).success ).toBe( true );
15
- expect( attributesSchema.safeParse( {} ).success ).toBe( true );
16
- } );
17
-
18
- it( 'rejects unknown attributes, including the removed cache shorthand', () => {
19
- expect( attributesSchema.safeParse( { cache: true } ).success ).toBe( false );
20
- expect( attributesSchema.safeParse( { unknown: 'x' } ).success ).toBe( false );
21
- } );
22
- } );
23
-
24
- describe( 'resolveMessageProviderOptions', () => {
25
- it( 'merges a referenced messageOptions set into per-message providerOptions', () => {
26
- const result = resolveMessageProviderOptions( textPrompt( {
27
- config: { messageOptions: { cached: { anthropic: { cacheControl: { type: 'ephemeral' } } } } },
28
- messages: [
29
- { role: 'system', content: 'Docs', attributes: { options: 'cached' } },
30
- { role: 'user', content: 'Hello' }
31
- ]
32
- } ) );
33
-
34
- expect( result ).toEqual( [
35
- {
36
- role: 'system',
37
- content: 'Docs',
38
- providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }
39
- },
40
- { role: 'user', content: 'Hello' }
41
- ] );
42
- } );
43
-
44
- it( 'merges multiple referenced sets onto one block', () => {
45
- const [ system ] = resolveMessageProviderOptions( textPrompt( {
46
- config: {
47
- messageOptions: {
48
- cached: { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } },
49
- openaiKey: { openai: { promptCacheKey: 'enrich-v1' } }
50
- }
51
- },
52
- messages: [ { role: 'system', content: 'Docs', attributes: { options: 'cached openaiKey' } } ]
53
- } ) );
54
-
55
- expect( system.providerOptions ).toEqual( {
56
- anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } },
57
- openai: { promptCacheKey: 'enrich-v1' }
58
- } );
59
- } );
60
-
61
- it( 'throws when the options attribute references an unknown set', () => {
62
- expect( () => resolveMessageProviderOptions( textPrompt( {
63
- messages: [ { role: 'user', content: 'Hello', attributes: { options: 'missing' } } ]
64
- } ) ) ).toThrow( FatalError );
65
- } );
66
-
67
- it( 'leaves messages without attributes unchanged', () => {
68
- const messages = [ { role: 'user', content: 'Hello' } ];
69
- expect( resolveMessageProviderOptions( textPrompt( { messages } ) ) ).toEqual( messages );
70
- } );
71
- } );