@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.
- package/package.json +2 -2
- package/src/agent.js +105 -83
- package/src/agent.spec.js +312 -225
- package/src/ai_provider.js +2 -2
- package/src/ai_sdk_options.js +93 -39
- package/src/ai_sdk_options.spec.js +314 -154
- package/src/consts.js +6 -0
- package/src/generate.js +81 -0
- package/src/generate.spec.js +466 -0
- package/src/index.d.ts +222 -242
- package/src/index.js +3 -4
- package/src/prompt/content.js +70 -0
- package/src/prompt/content.spec.js +208 -0
- package/src/prompt/{escape.js → interpolations.js} +11 -18
- package/src/prompt/interpolations.spec.js +109 -0
- package/src/prompt/loader.full.spec.js +293 -0
- package/src/prompt/loader.js +45 -39
- package/src/prompt/loader.spec.js +186 -289
- package/src/prompt/markup/attributes.js +52 -0
- package/src/prompt/markup/attributes.spec.js +132 -0
- package/src/prompt/markup/nodes.js +71 -0
- package/src/prompt/markup/nodes.spec.js +333 -0
- package/src/prompt/markup/tokenizer.js +3 -0
- package/src/prompt/markup/tokenizer.spec.js +149 -0
- package/src/prompt/markup/tokens.js +26 -0
- package/src/prompt/markup/tokens.spec.js +250 -0
- package/src/prompt/validations.js +98 -67
- package/src/prompt/validations.spec.js +204 -47
- package/src/{prompt/load_content.js → utils/file.js} +12 -15
- package/src/utils/file.spec.js +89 -0
- package/src/utils/models.js +15 -0
- package/src/utils/models.spec.js +119 -0
- package/src/utils/skills.js +74 -0
- package/src/utils/skills.spec.js +168 -0
- package/src/utils/sources.js +48 -0
- package/src/utils/sources.spec.js +122 -0
- package/src/utils/stream.js +22 -0
- package/src/utils/stream.spec.js +55 -0
- package/src/utils/tools.js +47 -0
- package/src/utils/tools.spec.js +167 -0
- package/src/utils/wrap.js +148 -0
- package/src/utils/wrap.spec.js +359 -0
- package/src/validations.js +149 -27
- package/src/validations.spec.js +451 -53
- package/src/ai_model.js +0 -60
- package/src/ai_model.spec.js +0 -259
- package/src/ai_sdk.js +0 -92
- package/src/ai_sdk.spec.js +0 -564
- package/src/prompt/block_options.js +0 -58
- package/src/prompt/block_options.spec.js +0 -71
- package/src/prompt/blocks.js +0 -47
- package/src/prompt/blocks.spec.js +0 -63
- package/src/prompt/escape.spec.js +0 -159
- package/src/prompt/load_content.spec.js +0 -83
- package/src/prompt/loader_validation.spec.js +0 -128
- package/src/prompt/parser.js +0 -16
- package/src/prompt/parser.spec.js +0 -186
- package/src/prompt/prepare_text.js +0 -27
- package/src/prompt/prepare_text.spec.js +0 -141
- package/src/prompt/skill.js +0 -128
- package/src/prompt/skill.spec.js +0 -172
- package/src/utils/message.js +0 -3
- package/src/utils/message.spec.js +0 -29
- package/src/utils/response_wrappers.js +0 -100
- package/src/utils/response_wrappers.spec.js +0 -240
- package/src/utils/source_extraction.js +0 -53
- package/src/utils/source_extraction.spec.js +0 -194
- package/src/utils/trace.js +0 -19
- package/src/utils/trace.spec.js +0 -112
package/src/ai_sdk.spec.js
DELETED
|
@@ -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
|
-
} );
|