@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
@@ -2,12 +2,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
 
3
3
  const loadModelImpl = vi.fn();
4
4
  const loadImageModelImpl = vi.fn();
5
- const loadToolsImpl = vi.fn();
5
+ const loadPromptToolsImpl = vi.fn();
6
+ const buildLoadSkillToolImpl = vi.fn();
6
7
 
7
- vi.mock( './ai_model.js', () => ( {
8
+ vi.mock( './utils/models.js', () => ( {
8
9
  loadTextModel: ( ...args ) => loadModelImpl( ...args ),
9
- loadImageModel: ( ...args ) => loadImageModelImpl( ...args ),
10
- loadTools: ( ...args ) => loadToolsImpl( ...args )
10
+ loadImageModel: ( ...args ) => loadImageModelImpl( ...args )
11
+ } ) );
12
+
13
+ vi.mock( './utils/tools.js', () => ( {
14
+ buildLoadSkillTool: ( ...args ) => buildLoadSkillToolImpl( ...args ),
15
+ loadPromptTools: ( ...args ) => loadPromptToolsImpl( ...args )
16
+ } ) );
17
+
18
+ vi.mock( 'ai', () => ( {
19
+ stepCountIs: count => ( { type: 'step-count', count } )
11
20
  } ) );
12
21
 
13
22
  const importSut = async () => import( './ai_sdk_options.js' );
@@ -17,6 +26,7 @@ const makeTextPrompt = config => ( {
17
26
  config: {
18
27
  provider: 'anthropic',
19
28
  model: 'claude-haiku-4-5',
29
+ maxSteps: 10,
20
30
  ...config
21
31
  },
22
32
  messages: [
@@ -37,194 +47,344 @@ const makeImagePrompt = config => ( {
37
47
  instructions: 'Generate a cinematic image of a NASCAR race at sunset.'
38
48
  } );
39
49
 
50
+ const writerSkill = {
51
+ name: 'writer',
52
+ description: 'Writes copy',
53
+ instructions: 'Do it.'
54
+ };
55
+
56
+ const skillsCatalog =
57
+ 'Available skills (use load_skill to get full instructions):\n- writer: Writes copy';
58
+
40
59
  describe( 'ai_sdk_options', () => {
41
60
  beforeEach( () => {
42
61
  vi.resetModules();
43
62
  vi.clearAllMocks();
44
63
  loadModelImpl.mockReturnValue( 'MODEL' );
45
64
  loadImageModelImpl.mockReturnValue( 'IMAGE_MODEL' );
46
- loadToolsImpl.mockReturnValue( null );
65
+ loadPromptToolsImpl.mockReturnValue( null );
66
+ buildLoadSkillToolImpl.mockImplementation( skills => ( { type: 'load_skill', skills } ) );
47
67
  } );
48
68
 
49
- it( 'maps loaded prompts to AI SDK text options', async () => {
50
- const prompt = makeTextPrompt( {
51
- temperature: 0.3,
52
- maxTokens: 1000,
53
- providerOptions: { anthropic: { effort: 'medium' } }
69
+ describe( 'loadAiSdkTextOptions', () => {
70
+ const loadText = async args => {
71
+ const { loadAiSdkTextOptions } = await importSut();
72
+ return loadAiSdkTextOptions( { skills: [], ...args } );
73
+ };
74
+
75
+ it( 'maps a loaded prompt to model, system, messages, and generation config', async () => {
76
+ const prompt = makeTextPrompt( {
77
+ temperature: 0.3,
78
+ maxOutputTokens: 1000,
79
+ providerOptions: { anthropic: { effort: 'medium' } }
80
+ } );
81
+
82
+ const result = await loadText( { prompt } );
83
+
84
+ expect( loadModelImpl ).toHaveBeenCalledWith( prompt );
85
+ expect( loadPromptToolsImpl ).toHaveBeenCalledWith( prompt );
86
+ expect( result ).toEqual( {
87
+ allowSystemInMessages: true,
88
+ maxRetries: 0,
89
+ model: 'MODEL',
90
+ system: [ { role: 'system', content: 'You are concise.' } ],
91
+ messages: [ { role: 'user', content: 'Hello' } ],
92
+ providerOptions: prompt.config.providerOptions,
93
+ temperature: 0.3,
94
+ maxOutputTokens: 1000
95
+ } );
96
+ } );
97
+
98
+ it( 'forwards all supported text generation options', async () => {
99
+ const generationOptions = {
100
+ frequencyPenalty: 0.2,
101
+ maxOutputTokens: 2000,
102
+ presencePenalty: 0.3,
103
+ providerOptions: { openai: { reasoningEffort: 'low' } },
104
+ seed: 42,
105
+ stopSequences: [ 'END' ],
106
+ temperature: 0.4,
107
+ topK: 40,
108
+ topP: 0.9
109
+ };
110
+
111
+ const result = await loadText( { prompt: makeTextPrompt( generationOptions ) } );
112
+
113
+ expect( result ).toMatchObject( generationOptions );
54
114
  } );
55
115
 
56
- const { loadAiSdkTextOptions } = await importSut();
57
- const result = loadAiSdkTextOptions( prompt );
116
+ it( 'preserves temperature 0', async () => {
117
+ const result = await loadText( { prompt: makeTextPrompt( { temperature: 0 } ) } );
58
118
 
59
- expect( loadModelImpl ).toHaveBeenCalledWith( prompt );
60
- expect( loadToolsImpl ).toHaveBeenCalledWith( prompt );
61
- expect( result ).toEqual( {
62
- model: 'MODEL',
63
- system: [ { role: 'system', content: 'You are concise.' } ],
64
- messages: [ { role: 'user', content: 'Hello' } ],
65
- providerOptions: prompt.config.providerOptions,
66
- temperature: 0.3,
67
- maxOutputTokens: 1000
119
+ expect( result.temperature ).toBe( 0 );
68
120
  } );
69
- } );
70
121
 
71
- it( 'preserves temperature 0 in text options', async () => {
72
- const prompt = makeTextPrompt( { temperature: 0 } );
122
+ it( 'throws when the prompt has no chat-style messages', async () => {
123
+ const { loadAiSdkTextOptions } = await importSut();
73
124
 
74
- const { loadAiSdkTextOptions } = await importSut();
75
- const result = loadAiSdkTextOptions( prompt );
125
+ expect( () => loadAiSdkTextOptions( { prompt: makeImagePrompt(), skills: [] } ) ).toThrow(
126
+ 'Prompt "image@v1" has no chat-style messages.'
127
+ );
128
+ expect( loadModelImpl ).not.toHaveBeenCalled();
129
+ expect( loadPromptToolsImpl ).not.toHaveBeenCalled();
130
+ } );
76
131
 
77
- expect( result.temperature ).toBe( 0 );
78
- } );
132
+ it( 'forwards per-message providerOptions when splitting system and messages', async () => {
133
+ const providerOptions = { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } };
134
+ const prompt = {
135
+ name: 'cache@v1',
136
+ config: {
137
+ provider: 'anthropic',
138
+ model: 'claude-sonnet-4-5'
139
+ },
140
+ messages: [
141
+ { role: 'system', content: 'Static', providerOptions },
142
+ { role: 'user', content: 'Hello' }
143
+ ],
144
+ instructions: null
145
+ };
146
+
147
+ const result = await loadText( { prompt } );
148
+
149
+ expect( result.system ).toEqual( [ {
150
+ role: 'system',
151
+ content: 'Static',
152
+ providerOptions
153
+ } ] );
154
+ expect( result.messages ).toEqual( [ { role: 'user', content: 'Hello' } ] );
155
+ } );
79
156
 
80
- it( 'adds provider tools when prompt config resolves tools', async () => {
81
- const prompt = makeTextPrompt( { tools: { googleSearch: {} } } );
82
- const tools = { googleSearch: { type: 'google-search-tool' } };
83
- loadToolsImpl.mockReturnValue( tools );
157
+ it( 'returns an empty system array when the prompt has no system block', async () => {
158
+ const prompt = {
159
+ name: 'no-system@v1',
160
+ config: { provider: 'anthropic', model: 'claude-haiku-4-5' },
161
+ messages: [ { role: 'user', content: 'Hello' } ],
162
+ instructions: null
163
+ };
84
164
 
85
- const { loadAiSdkTextOptions } = await importSut();
86
- const result = loadAiSdkTextOptions( prompt );
165
+ const result = await loadText( { prompt } );
87
166
 
88
- expect( result.tools ).toBe( tools );
89
- } );
167
+ expect( result.system ).toEqual( [] );
168
+ expect( result.messages ).toEqual( [ { role: 'user', content: 'Hello' } ] );
169
+ } );
90
170
 
91
- it( 'throws when text options receive a prompt without message blocks', async () => {
92
- const prompt = makeImagePrompt();
171
+ it( 'groups multiple system blocks into system and keeps them out of messages', async () => {
172
+ const prompt = {
173
+ name: 'multi-system@v1',
174
+ config: { provider: 'anthropic', model: 'claude-haiku-4-5' },
175
+ messages: [
176
+ { role: 'system', content: 'First' },
177
+ { role: 'system', content: 'Second' },
178
+ { role: 'user', content: 'Hello' }
179
+ ],
180
+ instructions: null
181
+ };
182
+
183
+ const result = await loadText( { prompt } );
184
+
185
+ expect( result.system ).toEqual( [
186
+ { role: 'system', content: 'First' },
187
+ { role: 'system', content: 'Second' }
188
+ ] );
189
+ expect( result.messages ).toEqual( [ { role: 'user', content: 'Hello' } ] );
190
+ } );
93
191
 
94
- const { loadAiSdkTextOptions } = await importSut();
192
+ it( 'omits tools and stopWhen when no tools are present', async () => {
193
+ const result = await loadText( { prompt: makeTextPrompt() } );
95
194
 
96
- expect( () => loadAiSdkTextOptions( prompt ) ).toThrow(
97
- 'Prompt "image@v1" has no chat-style messages.'
98
- );
99
- expect( loadModelImpl ).not.toHaveBeenCalled();
100
- expect( loadToolsImpl ).not.toHaveBeenCalled();
101
- } );
195
+ expect( result.tools ).toBeUndefined();
196
+ expect( result.stopWhen ).toBeUndefined();
197
+ expect( buildLoadSkillToolImpl ).not.toHaveBeenCalled();
198
+ } );
102
199
 
103
- it( 'maps loaded prompts to AI SDK image options', async () => {
104
- const images = [ Buffer.from( 'image-bytes' ) ];
105
- const mask = Buffer.from( 'mask-bytes' );
106
- const prompt = makeImagePrompt( {
107
- n: 2,
108
- maxImagesPerCall: 1,
109
- size: '1024x1024',
110
- aspectRatio: '1:1',
111
- seed: 42,
112
- temperature: 0.7,
113
- maxTokens: 1000,
114
- providerOptions: { openai: { quality: 'high' } }
115
- } );
116
-
117
- const { loadAiSdkImageOptions } = await importSut();
118
- const result = loadAiSdkImageOptions( { prompt, images, mask } );
119
-
120
- expect( loadImageModelImpl ).toHaveBeenCalledWith( prompt );
121
- expect( loadModelImpl ).not.toHaveBeenCalled();
122
- expect( loadToolsImpl ).not.toHaveBeenCalled();
123
- expect( result ).toEqual( {
124
- model: 'IMAGE_MODEL',
125
- prompt: {
126
- text: 'Generate a cinematic image of a NASCAR race at sunset.',
127
- images,
128
- mask
129
- },
130
- providerOptions: prompt.config.providerOptions,
131
- n: 2,
132
- maxImagesPerCall: 1,
133
- size: '1024x1024',
134
- aspectRatio: '1:1',
135
- seed: 42
136
- } );
137
- expect( result.temperature ).toBeUndefined();
138
- expect( result.maxOutputTokens ).toBeUndefined();
139
- } );
200
+ it( 'sets prompt tools and stopWhen from maxSteps', async () => {
201
+ const promptTools = { googleSearch: { type: 'google-search-tool' } };
202
+ loadPromptToolsImpl.mockReturnValue( promptTools );
140
203
 
141
- it( 'omits undefined image options while preserving explicit 0 seed', async () => {
142
- const prompt = makeImagePrompt( { seed: 0 } );
204
+ const result = await loadText( { prompt: makeTextPrompt( { tools: { googleSearch: {} }, maxSteps: 4 } ) } );
143
205
 
144
- const { loadAiSdkImageOptions } = await importSut();
145
- const result = loadAiSdkImageOptions( { prompt } );
206
+ expect( result.tools ).toEqual( promptTools );
207
+ expect( result.stopWhen ).toEqual( { type: 'step-count', count: 4 } );
208
+ } );
146
209
 
147
- expect( result ).toEqual( {
148
- model: 'IMAGE_MODEL',
149
- prompt: 'Generate a cinematic image of a NASCAR race at sunset.',
150
- providerOptions: undefined,
151
- seed: 0
210
+ it( 'lets caller tools override prompt tools on the same key', async () => {
211
+ loadPromptToolsImpl.mockReturnValue( {
212
+ googleSearch: { from: 'prompt' },
213
+ urlContext: { from: 'prompt' }
214
+ } );
215
+
216
+ const result = await loadText( {
217
+ prompt: makeTextPrompt(),
218
+ tools: { googleSearch: { from: 'caller' } }
219
+ } );
220
+
221
+ expect( result.tools ).toEqual( {
222
+ googleSearch: { from: 'caller' },
223
+ urlContext: { from: 'prompt' }
224
+ } );
152
225
  } );
153
- } );
154
226
 
155
- it( 'throws when image options receive a prompt without instructions', async () => {
156
- const prompt = makeTextPrompt();
227
+ it( 'appends the skills catalog to the first system message and adds load_skill', async () => {
228
+ const providerOptions = { anthropic: { cacheControl: { type: 'ephemeral' } } };
229
+ const prompt = makeTextPrompt();
230
+ prompt.messages[0] = { ...prompt.messages[0], providerOptions };
231
+
232
+ const result = await loadText( {
233
+ prompt,
234
+ skills: [ writerSkill ]
235
+ } );
236
+
237
+ expect( buildLoadSkillToolImpl ).toHaveBeenCalledWith( [ writerSkill ] );
238
+ expect( result.system ).toEqual( [
239
+ { role: 'system', content: `You are concise.\n\n${skillsCatalog}`, providerOptions }
240
+ ] );
241
+ expect( result.messages ).toEqual( [ { role: 'user', content: 'Hello' } ] );
242
+ expect( result.tools ).toEqual( {
243
+ load_skill: { type: 'load_skill', skills: [ writerSkill ] }
244
+ } );
245
+ expect( result.stopWhen ).toEqual( { type: 'step-count', count: 10 } );
246
+ } );
157
247
 
158
- const { loadAiSdkImageOptions } = await importSut();
248
+ it( 'adds a system message for the skills catalog when none exists', async () => {
249
+ const prompt = {
250
+ name: 'no-system@v1',
251
+ config: { provider: 'anthropic', model: 'claude-haiku-4-5', maxSteps: 10 },
252
+ messages: [ { role: 'user', content: 'Hello' } ],
253
+ instructions: null
254
+ };
159
255
 
160
- expect( () => loadAiSdkImageOptions( { prompt } ) ).toThrow(
161
- 'Prompt "test@v1" has no instructions.'
162
- );
163
- expect( loadImageModelImpl ).not.toHaveBeenCalled();
164
- } );
256
+ const result = await loadText( { prompt, skills: [ writerSkill ] } );
165
257
 
166
- it( 'resolves block attributes into per-message providerOptions', async () => {
167
- const prompt = {
168
- name: 'cache@v1',
169
- config: {
170
- provider: 'anthropic',
171
- model: 'claude-sonnet-4-5',
172
- messageOptions: { cached: { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } } }
173
- },
174
- messages: [
175
- { role: 'system', content: 'Static', attributes: { options: 'cached' } },
176
- { role: 'user', content: 'Hello' }
177
- ],
178
- instructions: null
179
- };
258
+ expect( result.system ).toEqual( [ { role: 'system', content: skillsCatalog } ] );
259
+ expect( result.messages ).toEqual( [ { role: 'user', content: 'Hello' } ] );
260
+ } );
180
261
 
181
- const { loadAiSdkTextOptions } = await importSut();
182
- const result = loadAiSdkTextOptions( prompt );
262
+ it( 'keeps load_skill when the caller uses the same key', async () => {
263
+ const result = await loadText( {
264
+ prompt: makeTextPrompt(),
265
+ tools: { load_skill: { from: 'caller' }, search: { from: 'caller' } },
266
+ skills: [ writerSkill ]
267
+ } );
268
+
269
+ expect( result.tools ).toEqual( {
270
+ load_skill: { type: 'load_skill', skills: [ writerSkill ] },
271
+ search: { from: 'caller' }
272
+ } );
273
+ } );
183
274
 
184
- expect( result.system ).toEqual( [
185
- {
186
- role: 'system',
187
- content: 'Static',
188
- providerOptions: { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } }
189
- }
190
- ] );
191
- expect( result.messages ).toEqual( [ { role: 'user', content: 'Hello' } ] );
192
- } );
275
+ it( 'forwards output, abortSignal, and caller stopWhen', async () => {
276
+ const output = { type: 'object' };
277
+ const abortSignal = AbortSignal.abort();
278
+ const stopWhen = { type: 'custom-stop' };
279
+
280
+ const result = await loadText( {
281
+ prompt: makeTextPrompt(),
282
+ output,
283
+ abortSignal,
284
+ stopWhen
285
+ } );
286
+
287
+ expect( result.output ).toBe( output );
288
+ expect( result.abortSignal ).toBe( abortSignal );
289
+ expect( result.stopWhen ).toBe( stopWhen );
290
+ } );
193
291
 
194
- it( 'returns an empty system array when the prompt has no system block', async () => {
195
- const prompt = {
196
- name: 'no-system@v1',
197
- config: { provider: 'anthropic', model: 'claude-haiku-4-5' },
198
- messages: [ { role: 'user', content: 'Hello' } ],
199
- instructions: null
200
- };
292
+ it( 'does not replace caller stopWhen with maxSteps when tools are present', async () => {
293
+ loadPromptToolsImpl.mockReturnValue( { googleSearch: { type: 'google-search-tool' } } );
294
+ const stopWhen = { type: 'custom-stop' };
201
295
 
202
- const { loadAiSdkTextOptions } = await importSut();
203
- const result = loadAiSdkTextOptions( prompt );
296
+ const result = await loadText( {
297
+ prompt: makeTextPrompt( { maxSteps: 4 } ),
298
+ stopWhen
299
+ } );
204
300
 
205
- expect( result.system ).toEqual( [] );
206
- expect( result.messages ).toEqual( [ { role: 'user', content: 'Hello' } ] );
301
+ expect( result.stopWhen ).toBe( stopWhen );
302
+ } );
303
+
304
+ it( 'sets toolChoice only when tools are present', async () => {
305
+ const withoutTools = await loadText( {
306
+ prompt: makeTextPrompt(),
307
+ toolChoice: 'required'
308
+ } );
309
+ expect( withoutTools.toolChoice ).toBeUndefined();
310
+
311
+ loadPromptToolsImpl.mockReturnValue( { googleSearch: { type: 'google-search-tool' } } );
312
+ const withTools = await loadText( {
313
+ prompt: makeTextPrompt(),
314
+ toolChoice: 'required'
315
+ } );
316
+ expect( withTools.toolChoice ).toBe( 'required' );
317
+ } );
207
318
  } );
208
319
 
209
- it( 'groups multiple system blocks into the system option and keeps them out of messages', async () => {
210
- const prompt = {
211
- name: 'multi-system@v1',
212
- config: { provider: 'anthropic', model: 'claude-haiku-4-5' },
213
- messages: [
214
- { role: 'system', content: 'First' },
215
- { role: 'system', content: 'Second' },
216
- { role: 'user', content: 'Hello' }
217
- ],
218
- instructions: null
219
- };
320
+ describe( 'loadAiSdkImageOptions', () => {
321
+ it( 'maps a loaded prompt to image options and ignores text generation config', async () => {
322
+ const images = [ Buffer.from( 'image-bytes' ) ];
323
+ const mask = Buffer.from( 'mask-bytes' );
324
+ const prompt = makeImagePrompt( {
325
+ n: 2,
326
+ maxImagesPerCall: 1,
327
+ size: '1024x1024',
328
+ aspectRatio: '1:1',
329
+ seed: 42,
330
+ temperature: 0.7,
331
+ maxOutputTokens: 1000,
332
+ providerOptions: { openai: { quality: 'high' } }
333
+ } );
334
+
335
+ const { loadAiSdkImageOptions } = await importSut();
336
+ const result = loadAiSdkImageOptions( { prompt, images, mask } );
337
+
338
+ expect( loadImageModelImpl ).toHaveBeenCalledWith( prompt );
339
+ expect( loadModelImpl ).not.toHaveBeenCalled();
340
+ expect( loadPromptToolsImpl ).not.toHaveBeenCalled();
341
+ expect( result ).toEqual( {
342
+ maxRetries: 0,
343
+ model: 'IMAGE_MODEL',
344
+ prompt: {
345
+ text: 'Generate a cinematic image of a NASCAR race at sunset.',
346
+ images,
347
+ mask
348
+ },
349
+ providerOptions: prompt.config.providerOptions,
350
+ n: 2,
351
+ maxImagesPerCall: 1,
352
+ size: '1024x1024',
353
+ aspectRatio: '1:1',
354
+ seed: 42
355
+ } );
356
+ expect( result.temperature ).toBeUndefined();
357
+ expect( result.maxOutputTokens ).toBeUndefined();
358
+ } );
220
359
 
221
- const { loadAiSdkTextOptions } = await importSut();
222
- const result = loadAiSdkTextOptions( prompt );
360
+ it( 'omits undefined image options while preserving explicit 0 seed', async () => {
361
+ const { loadAiSdkImageOptions } = await importSut();
362
+ const result = loadAiSdkImageOptions( { prompt: makeImagePrompt( { seed: 0 } ) } );
363
+
364
+ expect( result ).toEqual( {
365
+ maxRetries: 0,
366
+ model: 'IMAGE_MODEL',
367
+ prompt: 'Generate a cinematic image of a NASCAR race at sunset.',
368
+ providerOptions: undefined,
369
+ seed: 0
370
+ } );
371
+ } );
223
372
 
224
- expect( result.system ).toEqual( [
225
- { role: 'system', content: 'First' },
226
- { role: 'system', content: 'Second' }
227
- ] );
228
- expect( result.messages ).toEqual( [ { role: 'user', content: 'Hello' } ] );
373
+ it( 'throws when the prompt has no instructions', async () => {
374
+ const { loadAiSdkImageOptions } = await importSut();
375
+
376
+ expect( () => loadAiSdkImageOptions( { prompt: makeTextPrompt() } ) ).toThrow(
377
+ 'Prompt "test@v1" has no instructions.'
378
+ );
379
+ expect( loadImageModelImpl ).not.toHaveBeenCalled();
380
+ } );
381
+
382
+ it( 'forwards abortSignal', async () => {
383
+ const abortSignal = AbortSignal.abort();
384
+ const { loadAiSdkImageOptions } = await importSut();
385
+ const result = loadAiSdkImageOptions( { prompt: makeImagePrompt(), abortSignal } );
386
+
387
+ expect( result.abortSignal ).toBe( abortSignal );
388
+ } );
229
389
  } );
230
390
  } );
package/src/consts.js ADDED
@@ -0,0 +1,6 @@
1
+ /** Supported message roles */
2
+ export const Role = {
3
+ SYSTEM: 'system',
4
+ USER: 'user',
5
+ ASSISTANT: 'assistant'
6
+ };
@@ -0,0 +1,81 @@
1
+ import * as AI from 'ai';
2
+ import { loadPrompt } from './prompt/loader.js';
3
+ import { wrapGeneration, wrapStream } from './utils/wrap.js';
4
+ import { loadAiSdkTextOptions, loadAiSdkImageOptions } from './ai_sdk_options.js';
5
+ import { drainStream } from './utils/stream.js';
6
+ import { loadSkills } from './utils/skills.js';
7
+ import * as Validator from './validations.js';
8
+
9
+ export const generateText = async args => {
10
+ const { promptFile, variables, promptDir, ...aiOptions } = Validator.parseGenerateTextArgs( args );
11
+ const prompt = loadPrompt( promptFile, variables, promptDir );
12
+ const skills = loadSkills( prompt );
13
+
14
+ return wrapGeneration( {
15
+ name: 'generateText',
16
+ prompt,
17
+ fn: () => AI.generateText( loadAiSdkTextOptions( { prompt, skills, ...aiOptions } ) )
18
+ } );
19
+ };
20
+
21
+ export const streamText = args => {
22
+ const { promptFile, variables, promptDir, onFinish, onError, onChunk, ...aiOptions } = Validator.parseStreamTextArgs( args );
23
+ const prompt = loadPrompt( promptFile, variables, promptDir );
24
+ const skills = loadSkills( prompt );
25
+
26
+ return wrapStream( {
27
+ name: 'streamText',
28
+ prompt,
29
+ fn: ( { onFinishHook, onErrorHook } ) => AI.streamText( {
30
+ ...loadAiSdkTextOptions( { prompt, skills, ...aiOptions } ),
31
+ ...( onChunk && { onChunk } ),
32
+ onFinish: response => onFinishHook( response, onFinish ),
33
+ onError: event => onErrorHook( event, error => onError?.( { ...event, error } ) )
34
+ } )
35
+ } );
36
+ };
37
+
38
+ /**
39
+ * Generates a completed text response over streaming transport, invoking `onChunk` as parts arrive.
40
+ */
41
+ export const generateTextWithStreaming = async args => {
42
+ const { promptFile, variables, promptDir, onChunk, ...aiOptions } = Validator.parseGenerateTextWithStreamingArgs( args );
43
+ const prompt = loadPrompt( promptFile, variables, promptDir );
44
+ const skills = loadSkills( prompt );
45
+
46
+ return wrapGeneration( {
47
+ name: 'generateTextWithStreaming',
48
+ prompt,
49
+ fn: async () => {
50
+ const state = { response: null };
51
+ const stream = AI.streamText( {
52
+ ...loadAiSdkTextOptions( { prompt, skills, ...aiOptions } ),
53
+ ...( onChunk && { onChunk } ),
54
+ onFinish: res => {
55
+ state.response = res;
56
+ },
57
+ onError: _ => {} // Suppress AI-SDK console printing
58
+ } );
59
+
60
+ await drainStream( stream, aiOptions.abortSignal );
61
+
62
+ if ( !state.response ) {
63
+ throw new Error( 'Streaming generation completed without a response.' );
64
+ }
65
+
66
+ state.response.output = await stream.output;
67
+ return state.response;
68
+ }
69
+ } );
70
+ };
71
+
72
+ export const generateImage = async args => {
73
+ const { promptFile, promptDir, variables, ...aiOptions } = Validator.parseGenerateImageArgs( args );
74
+ const prompt = loadPrompt( promptFile, variables, promptDir );
75
+
76
+ return wrapGeneration( {
77
+ name: 'generateImage',
78
+ prompt,
79
+ fn: () => AI.generateImage( loadAiSdkImageOptions( { prompt, ...aiOptions } ) )
80
+ } );
81
+ };