@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,8 +1,10 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { ValidationError } from '@outputai/core';
3
- import { validatePrompt } from './validations.js';
3
+ import { parsePromptSchema } from './validations.js';
4
4
 
5
- describe( 'validatePrompt', () => {
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
- maxTokens: 1000
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( () => validatePrompt( validPrompt ) ).not.toThrow();
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( () => validatePrompt( minimalPrompt ) ).not.toThrow();
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( () => validatePrompt( promptWithThinking ) ).not.toThrow();
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( () => validatePrompt( promptWithThinkingDisabled ) ).not.toThrow();
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( () => validatePrompt( promptWithThinkingNoBudget ) ).not.toThrow();
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( () => validatePrompt( promptWithAnthropicOptions ) ).not.toThrow();
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( () => validatePrompt( promptWithOpenAIOptions ) ).not.toThrow();
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( () => validatePrompt( promptWithAzureOptions ) ).not.toThrow();
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( () => validatePrompt( promptWithMixedOptions ) ).not.toThrow();
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( () => validatePrompt( customProviderPrompt ) ).not.toThrow();
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( () => validatePrompt( imagePrompt ) ).not.toThrow();
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( () => validatePrompt( instructionsPrompt ) ).not.toThrow();
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( () => validatePrompt( messagesPrompt ) ).not.toThrow();
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( () => validatePrompt( whitespaceInstructionsPrompt ) ).toThrow( ValidationError );
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( () => validatePrompt( mixedPrompt ) ).toThrow( ValidationError );
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( () => validatePrompt( emptyPrompt ) ).toThrow( ValidationError );
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( () => validatePrompt( promptWithSkills ) ).not.toThrow();
418
+ expect( parse( promptWithSkills ).config.skills ).toEqual( [ './skills', './review.md' ] );
366
419
  } );
367
420
 
368
- it( 'should validate a prompt with a single skill path config', () => {
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( () => validatePrompt( promptWithSkill ) ).not.toThrow();
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( () => validatePrompt( promptWithTools ) ).not.toThrow();
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( () => validatePrompt( invalidToolsPrompt ) ).toThrow( ValidationError );
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( () => validatePrompt( invalidToolConfigPrompt ) ).toThrow( ValidationError );
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( () => validatePrompt( invalidSkillsPrompt ) ).toThrow( ValidationError );
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( () => validatePrompt( invalidImagePrompt ) ).toThrow( ValidationError );
646
+ expect( () => parse( invalidImagePrompt ) ).toThrow( ValidationError );
492
647
  } );
493
648
 
494
- it( 'should accept extra config fields via passthrough', () => {
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
- topP: 0.9,
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( () => validatePrompt( extraFieldsPrompt ) ).not.toThrow();
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( () => validatePrompt( emptyProviderPrompt ) ).toThrow( ValidationError );
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( () => validatePrompt( missingNamePrompt ) ).toThrow( ValidationError );
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( () => validatePrompt( promptWithBudgetTokensSnake ) ).not.toThrow();
724
+ expect( () => parse( promptWithBudgetTokensSnake ) ).not.toThrow();
572
725
  } );
573
726
 
574
- it( 'should allow snake_case fields in config via passthrough (no longer strict)', () => {
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( () => validatePrompt( maxTokensSnakeCase ) ).not.toThrow();
743
+ expect( () => parse( maxTokensSnakeCase ) ).toThrow( /"max_tokens" is not valid; use "maxTokens"/ );
591
744
  } );
592
745
 
593
- it( 'should validate the options attribute referencing messageOptions sets', () => {
594
- const promptWithMessageOptions = {
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
- { role: 'system', content: 'Docs.', attributes: { options: 'cached' } },
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( () => validatePrompt( promptWithMessageOptions ) ).not.toThrow();
766
+ expect( () => parse( promptWithMessageProviderOptions ) ).not.toThrow();
610
767
  } );
611
768
 
612
- it( 'should reject the removed cache shorthand as an unknown block attribute', () => {
613
- const cacheShorthandPrompt = {
614
- name: 'cache-shorthand-prompt',
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: { cache: true } }
777
+ { role: 'system', content: 'Static.', attributes: { options: 'cached' } }
621
778
  ]
622
779
  };
623
780
 
624
- expect( () => validatePrompt( cacheShorthandPrompt ) ).toThrow( ValidationError );
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( () => validatePrompt( unknownFieldPrompt ) ).toThrow( ValidationError );
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 loadFile = path => {
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
- const findContent = ( name, dir ) => {
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: loadFile( join( dir, entry.name ) ) };
32
+ return { dir, content: readFile( path ) };
26
33
  }
27
34
  if ( entry.isDirectory() && !entry.isSymbolicLink() ) {
28
- const result = findContent( name, join( dir, entry.name ) );
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
+ };