@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
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { ValidationError } from '@outputai/core';
|
|
3
|
-
import {
|
|
3
|
+
import { parsePromptSchema } from './validations.js';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const parse = prompt => parsePromptSchema( { fileDir: '/prompts', ...prompt } );
|
|
6
|
+
|
|
7
|
+
describe( 'parsePromptSchema', () => {
|
|
6
8
|
it( 'should validate a correct prompt with all required fields', () => {
|
|
7
9
|
const validPrompt = {
|
|
8
10
|
name: 'test-prompt',
|
|
@@ -10,7 +12,13 @@ describe( 'validatePrompt', () => {
|
|
|
10
12
|
provider: 'anthropic',
|
|
11
13
|
model: 'claude-3-opus-20240229',
|
|
12
14
|
temperature: 0.7,
|
|
13
|
-
|
|
15
|
+
maxOutputTokens: 1000,
|
|
16
|
+
topP: 0.9,
|
|
17
|
+
topK: 40,
|
|
18
|
+
presencePenalty: 0.2,
|
|
19
|
+
frequencyPenalty: 0.3,
|
|
20
|
+
stopSequences: [ 'END' ],
|
|
21
|
+
seed: 42
|
|
14
22
|
},
|
|
15
23
|
messages: [
|
|
16
24
|
{
|
|
@@ -20,7 +28,19 @@ describe( 'validatePrompt', () => {
|
|
|
20
28
|
]
|
|
21
29
|
};
|
|
22
30
|
|
|
23
|
-
expect( () =>
|
|
31
|
+
expect( () => parse( validPrompt ) ).not.toThrow();
|
|
32
|
+
} );
|
|
33
|
+
|
|
34
|
+
it.each( [ 'temperature', 'topK', 'topP' ] )( 'should reject a negative %s', field => {
|
|
35
|
+
expect( () => parse( {
|
|
36
|
+
name: `negative-${field}`,
|
|
37
|
+
config: {
|
|
38
|
+
provider: 'anthropic',
|
|
39
|
+
model: 'claude-3-opus-20240229',
|
|
40
|
+
[field]: -0.1
|
|
41
|
+
},
|
|
42
|
+
messages: [ { role: 'user', content: 'Hello' } ]
|
|
43
|
+
} ) ).toThrow( ValidationError );
|
|
24
44
|
} );
|
|
25
45
|
|
|
26
46
|
it( 'should validate a minimal prompt with only required fields', () => {
|
|
@@ -38,7 +58,40 @@ describe( 'validatePrompt', () => {
|
|
|
38
58
|
]
|
|
39
59
|
};
|
|
40
60
|
|
|
41
|
-
expect( (
|
|
61
|
+
expect( parse( minimalPrompt ).config.skills ).toEqual( [] );
|
|
62
|
+
expect( parse( minimalPrompt ).config.maxSteps ).toBe( 10 );
|
|
63
|
+
expect( parse( minimalPrompt ).variables ).toEqual( {} );
|
|
64
|
+
} );
|
|
65
|
+
|
|
66
|
+
it( 'should copy deprecated maxTokens to maxOutputTokens', () => {
|
|
67
|
+
const result = parse( {
|
|
68
|
+
name: 'deprecated-max-tokens',
|
|
69
|
+
config: {
|
|
70
|
+
provider: 'openai',
|
|
71
|
+
model: 'gpt-4',
|
|
72
|
+
maxTokens: 1000
|
|
73
|
+
},
|
|
74
|
+
messages: [ { role: 'user', content: 'Hello' } ]
|
|
75
|
+
} );
|
|
76
|
+
|
|
77
|
+
expect( result.config.maxTokens ).toBe( 1000 );
|
|
78
|
+
expect( result.config.maxOutputTokens ).toBe( 1000 );
|
|
79
|
+
} );
|
|
80
|
+
|
|
81
|
+
it( 'should keep maxOutputTokens when both token limit keys are set', () => {
|
|
82
|
+
const result = parse( {
|
|
83
|
+
name: 'both-token-limits',
|
|
84
|
+
config: {
|
|
85
|
+
provider: 'openai',
|
|
86
|
+
model: 'gpt-4',
|
|
87
|
+
maxTokens: 1000,
|
|
88
|
+
maxOutputTokens: 2000
|
|
89
|
+
},
|
|
90
|
+
messages: [ { role: 'user', content: 'Hello' } ]
|
|
91
|
+
} );
|
|
92
|
+
|
|
93
|
+
expect( result.config.maxTokens ).toBe( 1000 );
|
|
94
|
+
expect( result.config.maxOutputTokens ).toBe( 2000 );
|
|
42
95
|
} );
|
|
43
96
|
|
|
44
97
|
it( 'should validate a prompt with thinking providerOptions', () => {
|
|
@@ -62,7 +115,7 @@ describe( 'validatePrompt', () => {
|
|
|
62
115
|
]
|
|
63
116
|
};
|
|
64
117
|
|
|
65
|
-
expect( () =>
|
|
118
|
+
expect( () => parse( promptWithThinking ) ).not.toThrow();
|
|
66
119
|
} );
|
|
67
120
|
|
|
68
121
|
it( 'should validate a prompt with thinking type disabled', () => {
|
|
@@ -85,7 +138,7 @@ describe( 'validatePrompt', () => {
|
|
|
85
138
|
]
|
|
86
139
|
};
|
|
87
140
|
|
|
88
|
-
expect( () =>
|
|
141
|
+
expect( () => parse( promptWithThinkingDisabled ) ).not.toThrow();
|
|
89
142
|
} );
|
|
90
143
|
|
|
91
144
|
it( 'should validate a prompt with thinking without budgetTokens', () => {
|
|
@@ -108,7 +161,7 @@ describe( 'validatePrompt', () => {
|
|
|
108
161
|
]
|
|
109
162
|
};
|
|
110
163
|
|
|
111
|
-
expect( () =>
|
|
164
|
+
expect( () => parse( promptWithThinkingNoBudget ) ).not.toThrow();
|
|
112
165
|
} );
|
|
113
166
|
|
|
114
167
|
it( 'should validate a prompt with anthropic-specific providerOptions', () => {
|
|
@@ -136,7 +189,7 @@ describe( 'validatePrompt', () => {
|
|
|
136
189
|
]
|
|
137
190
|
};
|
|
138
191
|
|
|
139
|
-
expect( () =>
|
|
192
|
+
expect( () => parse( promptWithAnthropicOptions ) ).not.toThrow();
|
|
140
193
|
} );
|
|
141
194
|
|
|
142
195
|
it( 'should validate a prompt with openai-specific providerOptions', () => {
|
|
@@ -161,7 +214,7 @@ describe( 'validatePrompt', () => {
|
|
|
161
214
|
]
|
|
162
215
|
};
|
|
163
216
|
|
|
164
|
-
expect( () =>
|
|
217
|
+
expect( () => parse( promptWithOpenAIOptions ) ).not.toThrow();
|
|
165
218
|
} );
|
|
166
219
|
|
|
167
220
|
it( 'should validate a prompt with azure-specific providerOptions', () => {
|
|
@@ -185,7 +238,7 @@ describe( 'validatePrompt', () => {
|
|
|
185
238
|
]
|
|
186
239
|
};
|
|
187
240
|
|
|
188
|
-
expect( () =>
|
|
241
|
+
expect( () => parse( promptWithAzureOptions ) ).not.toThrow();
|
|
189
242
|
} );
|
|
190
243
|
|
|
191
244
|
it( 'should validate a prompt with mixed providerOptions including unknown fields', () => {
|
|
@@ -217,7 +270,7 @@ describe( 'validatePrompt', () => {
|
|
|
217
270
|
]
|
|
218
271
|
};
|
|
219
272
|
|
|
220
|
-
expect( () =>
|
|
273
|
+
expect( () => parse( promptWithMixedOptions ) ).not.toThrow();
|
|
221
274
|
} );
|
|
222
275
|
|
|
223
276
|
it( 'should accept custom provider names for dynamic providers', () => {
|
|
@@ -235,7 +288,7 @@ describe( 'validatePrompt', () => {
|
|
|
235
288
|
]
|
|
236
289
|
};
|
|
237
290
|
|
|
238
|
-
expect( () =>
|
|
291
|
+
expect( () => parse( customProviderPrompt ) ).not.toThrow();
|
|
239
292
|
} );
|
|
240
293
|
|
|
241
294
|
it( 'should validate image generation config fields', () => {
|
|
@@ -263,7 +316,7 @@ describe( 'validatePrompt', () => {
|
|
|
263
316
|
]
|
|
264
317
|
};
|
|
265
318
|
|
|
266
|
-
expect( () =>
|
|
319
|
+
expect( () => parse( imagePrompt ) ).not.toThrow();
|
|
267
320
|
} );
|
|
268
321
|
|
|
269
322
|
it( 'should validate a plain-instructions prompt without messages', () => {
|
|
@@ -277,7 +330,7 @@ describe( 'validatePrompt', () => {
|
|
|
277
330
|
instructions: 'Generate an image of a mountain.'
|
|
278
331
|
};
|
|
279
332
|
|
|
280
|
-
expect( () =>
|
|
333
|
+
expect( () => parse( instructionsPrompt ) ).not.toThrow();
|
|
281
334
|
} );
|
|
282
335
|
|
|
283
336
|
it( 'should validate role messages with null instructions', () => {
|
|
@@ -296,7 +349,7 @@ describe( 'validatePrompt', () => {
|
|
|
296
349
|
instructions: null
|
|
297
350
|
};
|
|
298
351
|
|
|
299
|
-
expect( () =>
|
|
352
|
+
expect( () => parse( messagesPrompt ) ).not.toThrow();
|
|
300
353
|
} );
|
|
301
354
|
|
|
302
355
|
it( 'should throw ValidationError when instructions are only whitespace', () => {
|
|
@@ -310,7 +363,7 @@ describe( 'validatePrompt', () => {
|
|
|
310
363
|
instructions: ' '
|
|
311
364
|
};
|
|
312
365
|
|
|
313
|
-
expect( () =>
|
|
366
|
+
expect( () => parse( whitespaceInstructionsPrompt ) ).toThrow( ValidationError );
|
|
314
367
|
} );
|
|
315
368
|
|
|
316
369
|
it( 'should throw ValidationError when both messages and instructions are present', () => {
|
|
@@ -329,7 +382,7 @@ describe( 'validatePrompt', () => {
|
|
|
329
382
|
instructions: 'Plain instructions should not be mixed with messages.'
|
|
330
383
|
};
|
|
331
384
|
|
|
332
|
-
expect( () =>
|
|
385
|
+
expect( () => parse( mixedPrompt ) ).toThrow( ValidationError );
|
|
333
386
|
} );
|
|
334
387
|
|
|
335
388
|
it( 'should throw ValidationError when neither messages nor instructions are present', () => {
|
|
@@ -343,7 +396,7 @@ describe( 'validatePrompt', () => {
|
|
|
343
396
|
instructions: null
|
|
344
397
|
};
|
|
345
398
|
|
|
346
|
-
expect( () =>
|
|
399
|
+
expect( () => parse( emptyPrompt ) ).toThrow( ValidationError );
|
|
347
400
|
} );
|
|
348
401
|
|
|
349
402
|
it( 'should validate a prompt with skill path config', () => {
|
|
@@ -362,10 +415,10 @@ describe( 'validatePrompt', () => {
|
|
|
362
415
|
]
|
|
363
416
|
};
|
|
364
417
|
|
|
365
|
-
expect( (
|
|
418
|
+
expect( parse( promptWithSkills ).config.skills ).toEqual( [ './skills', './review.md' ] );
|
|
366
419
|
} );
|
|
367
420
|
|
|
368
|
-
it( '
|
|
421
|
+
it( 'coerces a single skill path to an array', () => {
|
|
369
422
|
const promptWithSkill = {
|
|
370
423
|
name: 'skill-prompt',
|
|
371
424
|
config: {
|
|
@@ -381,7 +434,109 @@ describe( 'validatePrompt', () => {
|
|
|
381
434
|
]
|
|
382
435
|
};
|
|
383
436
|
|
|
384
|
-
expect( (
|
|
437
|
+
expect( parse( promptWithSkill ).config.skills ).toEqual( [ './skills' ] );
|
|
438
|
+
} );
|
|
439
|
+
|
|
440
|
+
it( 'coerces null skills to an empty array', () => {
|
|
441
|
+
const promptWithNullSkills = {
|
|
442
|
+
name: 'null-skills-prompt',
|
|
443
|
+
config: {
|
|
444
|
+
provider: 'anthropic',
|
|
445
|
+
model: 'claude-3-opus-20240229',
|
|
446
|
+
skills: null
|
|
447
|
+
},
|
|
448
|
+
messages: [
|
|
449
|
+
{
|
|
450
|
+
role: 'user',
|
|
451
|
+
content: 'Review this.'
|
|
452
|
+
}
|
|
453
|
+
]
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
expect( parse( promptWithNullSkills ).config.skills ).toEqual( [] );
|
|
457
|
+
} );
|
|
458
|
+
|
|
459
|
+
it( 'should validate prompt maxSteps', () => {
|
|
460
|
+
const promptWithMaxSteps = {
|
|
461
|
+
name: 'max-steps-prompt',
|
|
462
|
+
config: {
|
|
463
|
+
provider: 'anthropic',
|
|
464
|
+
model: 'claude-3-opus-20240229',
|
|
465
|
+
maxSteps: 4
|
|
466
|
+
},
|
|
467
|
+
messages: [
|
|
468
|
+
{
|
|
469
|
+
role: 'user',
|
|
470
|
+
content: 'Hello'
|
|
471
|
+
}
|
|
472
|
+
]
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
expect( parse( promptWithMaxSteps ).config.maxSteps ).toBe( 4 );
|
|
476
|
+
} );
|
|
477
|
+
|
|
478
|
+
it( 'should throw ValidationError for invalid maxSteps', () => {
|
|
479
|
+
for ( const maxSteps of [ 0, -1, 1.5 ] ) {
|
|
480
|
+
expect( () => parse( {
|
|
481
|
+
name: 'invalid-max-steps',
|
|
482
|
+
config: {
|
|
483
|
+
provider: 'anthropic',
|
|
484
|
+
model: 'claude-3-opus-20240229',
|
|
485
|
+
maxSteps
|
|
486
|
+
},
|
|
487
|
+
messages: [
|
|
488
|
+
{
|
|
489
|
+
role: 'user',
|
|
490
|
+
content: 'Hello'
|
|
491
|
+
}
|
|
492
|
+
]
|
|
493
|
+
} ) ).toThrow( ValidationError );
|
|
494
|
+
}
|
|
495
|
+
} );
|
|
496
|
+
|
|
497
|
+
it( 'throws ValidationError when fileDir is missing', () => {
|
|
498
|
+
expect( () => parsePromptSchema( {
|
|
499
|
+
name: 'no-file-dir',
|
|
500
|
+
config: {
|
|
501
|
+
provider: 'anthropic',
|
|
502
|
+
model: 'claude-3-opus-20240229'
|
|
503
|
+
},
|
|
504
|
+
messages: [
|
|
505
|
+
{
|
|
506
|
+
role: 'user',
|
|
507
|
+
content: 'Hello'
|
|
508
|
+
}
|
|
509
|
+
]
|
|
510
|
+
} ) ).toThrow( ValidationError );
|
|
511
|
+
} );
|
|
512
|
+
|
|
513
|
+
it( 'accepts scalar, object, and array variables', () => {
|
|
514
|
+
expect( parse( {
|
|
515
|
+
name: 'typed-variables',
|
|
516
|
+
config: {
|
|
517
|
+
provider: 'anthropic',
|
|
518
|
+
model: 'claude-3-opus-20240229'
|
|
519
|
+
},
|
|
520
|
+
messages: [
|
|
521
|
+
{
|
|
522
|
+
role: 'user',
|
|
523
|
+
content: 'Hello'
|
|
524
|
+
}
|
|
525
|
+
],
|
|
526
|
+
variables: {
|
|
527
|
+
name: 'Acme',
|
|
528
|
+
size: 250,
|
|
529
|
+
active: true,
|
|
530
|
+
company: { name: 'Acme' },
|
|
531
|
+
industries: [ 'SaaS', 'AI' ]
|
|
532
|
+
}
|
|
533
|
+
} ).variables ).toEqual( {
|
|
534
|
+
name: 'Acme',
|
|
535
|
+
size: 250,
|
|
536
|
+
active: true,
|
|
537
|
+
company: { name: 'Acme' },
|
|
538
|
+
industries: [ 'SaaS', 'AI' ]
|
|
539
|
+
} );
|
|
385
540
|
} );
|
|
386
541
|
|
|
387
542
|
it( 'should validate provider tool config records', () => {
|
|
@@ -406,7 +561,7 @@ describe( 'validatePrompt', () => {
|
|
|
406
561
|
]
|
|
407
562
|
};
|
|
408
563
|
|
|
409
|
-
expect( () =>
|
|
564
|
+
expect( () => parse( promptWithTools ) ).not.toThrow();
|
|
410
565
|
} );
|
|
411
566
|
|
|
412
567
|
it( 'should throw ValidationError when tools config is not a record', () => {
|
|
@@ -425,7 +580,7 @@ describe( 'validatePrompt', () => {
|
|
|
425
580
|
]
|
|
426
581
|
};
|
|
427
582
|
|
|
428
|
-
expect( () =>
|
|
583
|
+
expect( () => parse( invalidToolsPrompt ) ).toThrow( ValidationError );
|
|
429
584
|
} );
|
|
430
585
|
|
|
431
586
|
it( 'should throw ValidationError when a tool config is not a record', () => {
|
|
@@ -446,7 +601,7 @@ describe( 'validatePrompt', () => {
|
|
|
446
601
|
]
|
|
447
602
|
};
|
|
448
603
|
|
|
449
|
-
expect( () =>
|
|
604
|
+
expect( () => parse( invalidToolConfigPrompt ) ).toThrow( ValidationError );
|
|
450
605
|
} );
|
|
451
606
|
|
|
452
607
|
it( 'should throw ValidationError for invalid skill path config', () => {
|
|
@@ -465,7 +620,7 @@ describe( 'validatePrompt', () => {
|
|
|
465
620
|
]
|
|
466
621
|
};
|
|
467
622
|
|
|
468
|
-
expect( () =>
|
|
623
|
+
expect( () => parse( invalidSkillsPrompt ) ).toThrow( ValidationError );
|
|
469
624
|
} );
|
|
470
625
|
|
|
471
626
|
it( 'should throw ValidationError for invalid image generation config fields', () => {
|
|
@@ -488,18 +643,16 @@ describe( 'validatePrompt', () => {
|
|
|
488
643
|
]
|
|
489
644
|
};
|
|
490
645
|
|
|
491
|
-
expect( () =>
|
|
646
|
+
expect( () => parse( invalidImagePrompt ) ).toThrow( ValidationError );
|
|
492
647
|
} );
|
|
493
648
|
|
|
494
|
-
it( 'should
|
|
649
|
+
it( 'should reject unrecognized config fields', () => {
|
|
495
650
|
const extraFieldsPrompt = {
|
|
496
651
|
name: 'extra-fields-prompt',
|
|
497
652
|
config: {
|
|
498
653
|
provider: 'openai',
|
|
499
654
|
model: 'gpt-4',
|
|
500
|
-
|
|
501
|
-
seed: 42,
|
|
502
|
-
stopSequences: [ 'END' ]
|
|
655
|
+
unsupportedOption: true
|
|
503
656
|
},
|
|
504
657
|
messages: [
|
|
505
658
|
{
|
|
@@ -509,7 +662,7 @@ describe( 'validatePrompt', () => {
|
|
|
509
662
|
]
|
|
510
663
|
};
|
|
511
664
|
|
|
512
|
-
expect( () =>
|
|
665
|
+
expect( () => parse( extraFieldsPrompt ) ).toThrow( /unrecognized key/i );
|
|
513
666
|
} );
|
|
514
667
|
|
|
515
668
|
it( 'should throw ValidationError when provider is empty string', () => {
|
|
@@ -527,7 +680,7 @@ describe( 'validatePrompt', () => {
|
|
|
527
680
|
]
|
|
528
681
|
};
|
|
529
682
|
|
|
530
|
-
expect( () =>
|
|
683
|
+
expect( () => parse( emptyProviderPrompt ) ).toThrow( ValidationError );
|
|
531
684
|
} );
|
|
532
685
|
|
|
533
686
|
it( 'should throw ValidationError when required fields are missing', () => {
|
|
@@ -544,7 +697,7 @@ describe( 'validatePrompt', () => {
|
|
|
544
697
|
]
|
|
545
698
|
};
|
|
546
699
|
|
|
547
|
-
expect( () =>
|
|
700
|
+
expect( () => parse( missingNamePrompt ) ).toThrow( ValidationError );
|
|
548
701
|
} );
|
|
549
702
|
|
|
550
703
|
it( 'should pass through budget_tokens in thinking and warn about snake_case', () => {
|
|
@@ -568,10 +721,10 @@ describe( 'validatePrompt', () => {
|
|
|
568
721
|
]
|
|
569
722
|
};
|
|
570
723
|
|
|
571
|
-
expect( () =>
|
|
724
|
+
expect( () => parse( promptWithBudgetTokensSnake ) ).not.toThrow();
|
|
572
725
|
} );
|
|
573
726
|
|
|
574
|
-
it( 'should
|
|
727
|
+
it( 'should suggest camelCase when a snake_case config key matches a known field', () => {
|
|
575
728
|
const maxTokensSnakeCase = {
|
|
576
729
|
name: 'test-prompt',
|
|
577
730
|
config: {
|
|
@@ -587,11 +740,11 @@ describe( 'validatePrompt', () => {
|
|
|
587
740
|
]
|
|
588
741
|
};
|
|
589
742
|
|
|
590
|
-
expect( () =>
|
|
743
|
+
expect( () => parse( maxTokensSnakeCase ) ).toThrow( /"max_tokens" is not valid; use "maxTokens"/ );
|
|
591
744
|
} );
|
|
592
745
|
|
|
593
|
-
it( 'should validate
|
|
594
|
-
const
|
|
746
|
+
it( 'should validate per-message providerOptions', () => {
|
|
747
|
+
const promptWithMessageProviderOptions = {
|
|
595
748
|
name: 'message-options-prompt',
|
|
596
749
|
config: {
|
|
597
750
|
provider: 'anthropic',
|
|
@@ -601,27 +754,31 @@ describe( 'validatePrompt', () => {
|
|
|
601
754
|
}
|
|
602
755
|
},
|
|
603
756
|
messages: [
|
|
604
|
-
{
|
|
757
|
+
{
|
|
758
|
+
role: 'system',
|
|
759
|
+
content: 'Docs.',
|
|
760
|
+
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }
|
|
761
|
+
},
|
|
605
762
|
{ role: 'user', content: 'Question' }
|
|
606
763
|
]
|
|
607
764
|
};
|
|
608
765
|
|
|
609
|
-
expect( () =>
|
|
766
|
+
expect( () => parse( promptWithMessageProviderOptions ) ).not.toThrow();
|
|
610
767
|
} );
|
|
611
768
|
|
|
612
|
-
it( 'should reject
|
|
613
|
-
const
|
|
614
|
-
name: '
|
|
769
|
+
it( 'should reject leftover attributes on a message', () => {
|
|
770
|
+
const leftoverAttributesPrompt = {
|
|
771
|
+
name: 'leftover-attributes-prompt',
|
|
615
772
|
config: {
|
|
616
773
|
provider: 'anthropic',
|
|
617
774
|
model: 'claude-sonnet-4-5'
|
|
618
775
|
},
|
|
619
776
|
messages: [
|
|
620
|
-
{ role: 'system', content: 'Static.', attributes: {
|
|
777
|
+
{ role: 'system', content: 'Static.', attributes: { options: 'cached' } }
|
|
621
778
|
]
|
|
622
779
|
};
|
|
623
780
|
|
|
624
|
-
expect( () =>
|
|
781
|
+
expect( () => parse( leftoverAttributesPrompt ) ).toThrow( ValidationError );
|
|
625
782
|
} );
|
|
626
783
|
|
|
627
784
|
it( 'should throw ValidationError for unknown top-level message fields', () => {
|
|
@@ -636,6 +793,6 @@ describe( 'validatePrompt', () => {
|
|
|
636
793
|
]
|
|
637
794
|
};
|
|
638
795
|
|
|
639
|
-
expect( () =>
|
|
796
|
+
expect( () => parse( unknownFieldPrompt ) ).toThrow( ValidationError );
|
|
640
797
|
} );
|
|
641
798
|
} );
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { join } from 'path';
|
|
2
2
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { Path } from '@outputai/core/sdk/helpers';
|
|
4
3
|
import { FatalError } from '@outputai/core';
|
|
5
4
|
|
|
6
5
|
const scanDir = dir => {
|
|
@@ -11,7 +10,7 @@ const scanDir = dir => {
|
|
|
11
10
|
}
|
|
12
11
|
};
|
|
13
12
|
|
|
14
|
-
const
|
|
13
|
+
const readFile = path => {
|
|
15
14
|
try {
|
|
16
15
|
return readFileSync( path, 'utf-8' );
|
|
17
16
|
} catch ( error ) {
|
|
@@ -19,13 +18,21 @@ const loadFile = path => {
|
|
|
19
18
|
}
|
|
20
19
|
};
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Recursively search for a file by name and return both its content and containing directory.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} dir - Directory to search
|
|
25
|
+
* @param {string} name - File name to find
|
|
26
|
+
* @returns {{ content: string, dir: string } | null}
|
|
27
|
+
*/
|
|
28
|
+
export const searchAndReadFile = ( dir, name ) => {
|
|
23
29
|
for ( const entry of scanDir( dir ) ) {
|
|
30
|
+
const path = join( dir, entry.name );
|
|
24
31
|
if ( entry.name === name ) {
|
|
25
|
-
return { dir, content:
|
|
32
|
+
return { dir, content: readFile( path ) };
|
|
26
33
|
}
|
|
27
34
|
if ( entry.isDirectory() && !entry.isSymbolicLink() ) {
|
|
28
|
-
const result =
|
|
35
|
+
const result = searchAndReadFile( path, name );
|
|
29
36
|
if ( result ) {
|
|
30
37
|
return result;
|
|
31
38
|
}
|
|
@@ -33,13 +40,3 @@ const findContent = ( name, dir ) => {
|
|
|
33
40
|
}
|
|
34
41
|
return null;
|
|
35
42
|
};
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Recursively search for a file by name and return both its content and containing directory.
|
|
39
|
-
*
|
|
40
|
-
* @param {string} name - File name to find
|
|
41
|
-
* @param {string} [dir] - Directory to search, defaults to invocation directory
|
|
42
|
-
* @returns {{ content: string, dir: string } | null}
|
|
43
|
-
*/
|
|
44
|
-
export const loadContent = ( name, dir = Path.resolveInvocationDir() ) =>
|
|
45
|
-
findContent( name, dir ) ?? null;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { FatalError } from '@outputai/core';
|
|
6
|
+
import { searchAndReadFile } from './file.js';
|
|
7
|
+
|
|
8
|
+
const dirs = [];
|
|
9
|
+
|
|
10
|
+
const tempDir = () => {
|
|
11
|
+
const dir = mkdtempSync( join( tmpdir(), 'search-and-read-' ) );
|
|
12
|
+
dirs.push( dir );
|
|
13
|
+
return dir;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
afterEach( () => {
|
|
17
|
+
for ( const dir of dirs.splice( 0 ) ) {
|
|
18
|
+
rmSync( dir, { recursive: true, force: true } );
|
|
19
|
+
}
|
|
20
|
+
} );
|
|
21
|
+
|
|
22
|
+
describe( 'searchAndReadFile', () => {
|
|
23
|
+
it( 'reads a file in the search directory', () => {
|
|
24
|
+
const dir = tempDir();
|
|
25
|
+
writeFileSync( join( dir, 'notes.txt' ), 'hello' );
|
|
26
|
+
|
|
27
|
+
expect( searchAndReadFile( dir, 'notes.txt' ) ).toEqual( {
|
|
28
|
+
dir,
|
|
29
|
+
content: 'hello'
|
|
30
|
+
} );
|
|
31
|
+
} );
|
|
32
|
+
|
|
33
|
+
it( 'reads a file from a nested subdirectory and returns that directory', () => {
|
|
34
|
+
const dir = tempDir();
|
|
35
|
+
const nested = join( dir, 'prompts' );
|
|
36
|
+
mkdirSync( nested );
|
|
37
|
+
writeFileSync( join( nested, 'writer@v1.prompt' ), 'Write it.' );
|
|
38
|
+
|
|
39
|
+
expect( searchAndReadFile( dir, 'writer@v1.prompt' ) ).toEqual( {
|
|
40
|
+
dir: nested,
|
|
41
|
+
content: 'Write it.'
|
|
42
|
+
} );
|
|
43
|
+
} );
|
|
44
|
+
|
|
45
|
+
it( 'continues into later siblings after a subdirectory miss', () => {
|
|
46
|
+
const dir = tempDir();
|
|
47
|
+
mkdirSync( join( dir, 'empty' ) );
|
|
48
|
+
const found = join( dir, 'found' );
|
|
49
|
+
mkdirSync( found );
|
|
50
|
+
writeFileSync( join( found, 'target.txt' ), 'here' );
|
|
51
|
+
|
|
52
|
+
expect( searchAndReadFile( dir, 'target.txt' ) ).toEqual( {
|
|
53
|
+
dir: found,
|
|
54
|
+
content: 'here'
|
|
55
|
+
} );
|
|
56
|
+
} );
|
|
57
|
+
|
|
58
|
+
it( 'skips symbolic-link directories', () => {
|
|
59
|
+
const dir = tempDir();
|
|
60
|
+
const hidden = join( dir, 'hidden' );
|
|
61
|
+
const search = join( dir, 'search' );
|
|
62
|
+
mkdirSync( hidden );
|
|
63
|
+
mkdirSync( search );
|
|
64
|
+
writeFileSync( join( hidden, 'secret.txt' ), 'nope' );
|
|
65
|
+
symlinkSync( hidden, join( search, 'link' ) );
|
|
66
|
+
|
|
67
|
+
expect( searchAndReadFile( search, 'secret.txt' ) ).toBeNull();
|
|
68
|
+
} );
|
|
69
|
+
|
|
70
|
+
it( 'returns null when the file is not found', () => {
|
|
71
|
+
const dir = tempDir();
|
|
72
|
+
writeFileSync( join( dir, 'other.txt' ), 'x' );
|
|
73
|
+
|
|
74
|
+
expect( searchAndReadFile( dir, 'missing.txt' ) ).toBeNull();
|
|
75
|
+
} );
|
|
76
|
+
|
|
77
|
+
it( 'returns null for an empty directory', () => {
|
|
78
|
+
expect( searchAndReadFile( tempDir(), 'missing.txt' ) ).toBeNull();
|
|
79
|
+
} );
|
|
80
|
+
|
|
81
|
+
it( 'throws FatalError when the search directory cannot be scanned', () => {
|
|
82
|
+
const missing = join( tempDir(), 'gone' );
|
|
83
|
+
|
|
84
|
+
expect( () => searchAndReadFile( missing, 'notes.txt' ) ).toThrow( FatalError );
|
|
85
|
+
expect( () => searchAndReadFile( missing, 'notes.txt' ) ).toThrow(
|
|
86
|
+
`Error scanning directory "${missing}"`
|
|
87
|
+
);
|
|
88
|
+
} );
|
|
89
|
+
} );
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ValidationError } from '@outputai/core';
|
|
2
|
+
import { getProvider } from '../ai_provider.js';
|
|
3
|
+
|
|
4
|
+
/** Load a model from a prompt */
|
|
5
|
+
export const loadTextModel = prompt => getProvider( prompt.config.provider )( prompt.config.model );
|
|
6
|
+
|
|
7
|
+
/** Load an image model from a prompt. */
|
|
8
|
+
export const loadImageModel = prompt => {
|
|
9
|
+
const provider = getProvider( prompt.config.provider );
|
|
10
|
+
const imageModelFactory = provider.image ?? provider.imageModel;
|
|
11
|
+
if ( typeof imageModelFactory !== 'function' ) {
|
|
12
|
+
throw new ValidationError( `Provider "${prompt.config.provider}" used by prompt "${prompt.name}" does not support image models.` );
|
|
13
|
+
}
|
|
14
|
+
return imageModelFactory( prompt.config.model );
|
|
15
|
+
};
|