@outputai/llm 0.6.1-dev.daae905.0 → 0.6.1-next.2cc4685.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 (45) hide show
  1. package/package.json +2 -2
  2. package/src/agent.js +15 -9
  3. package/src/agent.spec.js +295 -214
  4. package/src/ai_model.js +79 -36
  5. package/src/ai_model.spec.js +31 -13
  6. package/src/ai_sdk.js +55 -79
  7. package/src/ai_sdk.spec.js +464 -611
  8. package/src/ai_sdk_options.js +61 -0
  9. package/src/ai_sdk_options.spec.js +164 -0
  10. package/src/cost/index.js +1 -1
  11. package/src/index.d.ts +230 -175
  12. package/src/index.js +2 -2
  13. package/src/prompt/escape.js +65 -0
  14. package/src/prompt/escape.spec.js +159 -0
  15. package/src/{load_content.js → prompt/load_content.js} +1 -22
  16. package/src/{load_content.spec.js → prompt/load_content.spec.js} +6 -6
  17. package/src/prompt/loader.js +49 -0
  18. package/src/prompt/loader.spec.js +274 -0
  19. package/src/{prompt_loader_validation.spec.js → prompt/loader_validation.spec.js} +40 -7
  20. package/src/prompt/parser.js +19 -0
  21. package/src/{parser.spec.js → prompt/parser.spec.js} +74 -29
  22. package/src/prompt/prepare_text.js +27 -0
  23. package/src/prompt/prepare_text.spec.js +141 -0
  24. package/src/{skill.js → prompt/skill.js} +19 -0
  25. package/src/prompt/skill.spec.js +172 -0
  26. package/src/{prompt_validations.js → prompt/validations.js} +32 -6
  27. package/src/{prompt_validations.spec.js → prompt/validations.spec.js} +189 -1
  28. package/src/utils/__fixtures__/image_response.json +38 -0
  29. package/src/utils/__fixtures__/stream_response.json +294 -0
  30. package/src/utils/__fixtures__/text_response.json +201 -0
  31. package/src/utils/error_handler.js +65 -0
  32. package/src/utils/error_handler.spec.js +195 -0
  33. package/src/utils/image.js +10 -0
  34. package/src/utils/image.spec.js +20 -0
  35. package/src/utils/response_wrappers.js +46 -19
  36. package/src/utils/response_wrappers.spec.js +130 -70
  37. package/src/utils/source_extraction.js +17 -27
  38. package/src/utils/trace.js +2 -3
  39. package/src/utils/trace.spec.js +9 -13
  40. package/src/validations.js +54 -2
  41. package/src/validations.spec.js +166 -0
  42. package/src/parser.js +0 -28
  43. package/src/prompt_loader.js +0 -80
  44. package/src/prompt_loader.spec.js +0 -358
  45. package/src/skill.d.ts +0 -49
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Liquid } from 'liquidjs';
3
+ import { decode, encodeFilter, escape, setupLiquidEncodeFilter } from './escape.js';
4
+
5
+ describe( 'encodeFilter', () => {
6
+ it( 'encodes < to &lt;', () => {
7
+ expect( encodeFilter( '<' ) ).toBe( '&lt;' );
8
+ } );
9
+
10
+ it( 'encodes > to &gt;', () => {
11
+ expect( encodeFilter( '>' ) ).toBe( '&gt;' );
12
+ } );
13
+
14
+ it( 'encodes & to &amp;', () => {
15
+ expect( encodeFilter( '&' ) ).toBe( '&amp;' );
16
+ } );
17
+
18
+ it( 'encodes a string with multiple special characters in one pass', () => {
19
+ expect( encodeFilter( '<a & b>' ) ).toBe( '&lt;a &amp; b&gt;' );
20
+ } );
21
+
22
+ it( 'encodes a tag-shaped substring so the parser cannot tokenize it', () => {
23
+ expect( encodeFilter( '<system>x</system>' ) ).toBe( '&lt;system&gt;x&lt;/system&gt;' );
24
+ } );
25
+
26
+ it( 'returns an empty string for null', () => {
27
+ expect( encodeFilter( null ) ).toBe( '' );
28
+ } );
29
+
30
+ it( 'returns an empty string for undefined', () => {
31
+ expect( encodeFilter( undefined ) ).toBe( '' );
32
+ } );
33
+
34
+ it( 'coerces numbers to string before encoding', () => {
35
+ expect( encodeFilter( 42 ) ).toBe( '42' );
36
+ } );
37
+
38
+ it( 'coerces booleans to string before encoding', () => {
39
+ expect( encodeFilter( true ) ).toBe( 'true' );
40
+ expect( encodeFilter( false ) ).toBe( 'false' );
41
+ } );
42
+
43
+ it( 'passes empty strings through unchanged', () => {
44
+ expect( encodeFilter( '' ) ).toBe( '' );
45
+ } );
46
+
47
+ it( 'passes plain text through unchanged', () => {
48
+ expect( encodeFilter( 'hello world' ) ).toBe( 'hello world' );
49
+ } );
50
+ } );
51
+
52
+ describe( 'setupLiquidEncodeFilter', () => {
53
+ it( 'registers the safety filter on a Liquid instance', async () => {
54
+ const liquid = new Liquid();
55
+
56
+ setupLiquidEncodeFilter( liquid );
57
+
58
+ await expect( liquid.parseAndRender( '{{ value | __var_safe }}', {
59
+ value: '<system>x</system>'
60
+ } ) ).resolves.toBe( '&lt;system&gt;x&lt;/system&gt;' );
61
+ } );
62
+ } );
63
+
64
+ describe( 'escape', () => {
65
+ it( 'rewrites a single {{ var }} to append the safety filter', () => {
66
+ expect( escape( '{{ name }}' ) ).toBe( '{{ name | __var_safe }}' );
67
+ } );
68
+
69
+ it( 'rewrites multiple expressions in the same string', () => {
70
+ expect( escape( '{{ a }} and {{ b }}' ) ).toBe(
71
+ '{{ a | __var_safe }} and {{ b | __var_safe }}'
72
+ );
73
+ } );
74
+
75
+ it( 'appends the safety filter last in an existing filter chain', () => {
76
+ expect( escape( '{{ x | upcase }}' ) ).toBe(
77
+ '{{ x | upcase | __var_safe }}'
78
+ );
79
+ } );
80
+
81
+ it( 'handles longer filter chains', () => {
82
+ expect( escape( '{{ x | a | b }}' ) ).toBe(
83
+ '{{ x | a | b | __var_safe }}'
84
+ );
85
+ } );
86
+
87
+ it( 'handles dotted property paths', () => {
88
+ expect( escape( '{{ obj.field }}' ) ).toBe(
89
+ '{{ obj.field | __var_safe }}'
90
+ );
91
+ } );
92
+
93
+ it( 'preserves a {% raw %} block untouched even when it contains {{ ... }}', () => {
94
+ const input = '{% raw %}{{ literal }}{% endraw %}';
95
+ expect( escape( input ) ).toBe( input );
96
+ } );
97
+
98
+ it( 'rewrites {{ ... }} outside a raw block while preserving the raw block', () => {
99
+ expect( escape( '{{ a }}{% raw %}{{ b }}{% endraw %}{{ c }}' ) ).toBe(
100
+ '{{ a | __var_safe }}{% raw %}{{ b }}{% endraw %}{{ c | __var_safe }}'
101
+ );
102
+ } );
103
+
104
+ it( 'leaves {% if %} control tags untouched but still arms {{ ... }} inside them', () => {
105
+ expect( escape( '{% if cond %}{{ x }}{% endif %}' ) ).toBe(
106
+ '{% if cond %}{{ x | __var_safe }}{% endif %}'
107
+ );
108
+ } );
109
+
110
+ it( 'leaves {% for %} control tags untouched but still arms {{ ... }} inside them', () => {
111
+ expect( escape( '{% for x in xs %}{{ x }}{% endfor %}' ) ).toBe(
112
+ '{% for x in xs %}{{ x | __var_safe }}{% endfor %}'
113
+ );
114
+ } );
115
+
116
+ it( 'normalizes interior whitespace via expressionContent.trim()', () => {
117
+ expect( escape( '{{x}}' ) ).toBe( '{{ x | __var_safe }}' );
118
+ expect( escape( '{{ x }}' ) ).toBe( '{{ x | __var_safe }}' );
119
+ } );
120
+
121
+ it( 'returns the input unchanged when there are no {{ ... }} expressions', () => {
122
+ expect( escape( '<user>plain text</user>' ) ).toBe(
123
+ '<user>plain text</user>'
124
+ );
125
+ } );
126
+
127
+ it( 'handles an empty string', () => {
128
+ expect( escape( '' ) ).toBe( '' );
129
+ } );
130
+ } );
131
+
132
+ describe( 'decode', () => {
133
+ it( 'decodes XML entities in a string', () => {
134
+ expect( decode( 'R&amp;D &lt; Speed &gt; &quot;Limits&quot;' ) ).toBe( 'R&D < Speed > "Limits"' );
135
+ } );
136
+
137
+ it( 'decodes XML entities recursively in arrays and plain objects', () => {
138
+ expect( decode( {
139
+ label: 'R&amp;D',
140
+ values: [ 'A &lt; B' ],
141
+ nested: {
142
+ title: '&quot;Race&quot;'
143
+ }
144
+ } ) ).toEqual( {
145
+ label: 'R&D',
146
+ values: [ 'A < B' ],
147
+ nested: {
148
+ title: '"Race"'
149
+ }
150
+ } );
151
+ } );
152
+
153
+ it( 'returns non-string scalar values unchanged', () => {
154
+ expect( decode( null ) ).toBeNull();
155
+ expect( decode( undefined ) ).toBeUndefined();
156
+ expect( decode( 42 ) ).toBe( 42 );
157
+ expect( decode( true ) ).toBe( true );
158
+ } );
159
+ } );
@@ -34,33 +34,12 @@ const findContent = ( name, dir ) => {
34
34
  return null;
35
35
  };
36
36
 
37
- /**
38
- * Recursively search for a file by its name and load its content.
39
- *
40
- * @param {string} name - Name of the file load its content
41
- * @param {string} [dir] - The directory to search for the file, defaults to invocation directory
42
- * @returns {string | null} - File content or null if not found
43
- */
44
- export const loadContent = ( name, dir = resolveInvocationDir() ) =>
45
- findContent( name, dir )?.content ?? null;
46
-
47
- /**
48
- * Recursively search for a file by name and return the directory containing it.
49
- *
50
- * @param {string} name - File name to find
51
- * @param {string} [dir] - Directory to search, defaults to invocation directory
52
- * @returns {string | null} - Directory path containing the file, or null if not found
53
- */
54
- export const findContentDir = ( name, dir = resolveInvocationDir() ) =>
55
- findContent( name, dir )?.dir ?? null;
56
-
57
37
  /**
58
38
  * Recursively search for a file by name and return both its content and containing directory.
59
- * More efficient than calling loadContent + findContentDir separately (single scan).
60
39
  *
61
40
  * @param {string} name - File name to find
62
41
  * @param {string} [dir] - Directory to search, defaults to invocation directory
63
42
  * @returns {{ content: string, dir: string } | null}
64
43
  */
65
- export const loadContentWithDir = ( name, dir = resolveInvocationDir() ) =>
44
+ export const loadContent = ( name, dir = resolveInvocationDir() ) =>
66
45
  findContent( name, dir ) ?? null;
@@ -45,9 +45,9 @@ describe( 'loadContent', () => {
45
45
  state.entries[tempDir] = [ dirEntry( 'test.txt' ) ];
46
46
 
47
47
  const { loadContent } = await import( './load_content.js' );
48
- const content = loadContent( 'test.txt' );
48
+ const result = loadContent( 'test.txt' );
49
49
 
50
- expect( content ).toBe( testContent );
50
+ expect( result ).toEqual( { content: testContent, dir: tempDir } );
51
51
  } );
52
52
 
53
53
  it( 'loads file from nested subdirectory via recursion', async () => {
@@ -63,9 +63,9 @@ describe( 'loadContent', () => {
63
63
  state.entries[subDir] = [ dirEntry( 'nested.txt' ) ];
64
64
 
65
65
  const { loadContent } = await import( './load_content.js' );
66
- const content = loadContent( 'nested.txt' );
66
+ const result = loadContent( 'nested.txt' );
67
67
 
68
- expect( content ).toBe( testContent );
68
+ expect( result ).toEqual( { content: testContent, dir: subDir } );
69
69
  } );
70
70
 
71
71
  it( 'returns null when file does not exist', async () => {
@@ -74,8 +74,8 @@ describe( 'loadContent', () => {
74
74
  state.entries[tempDir] = [ dirEntry( 'other.txt' ) ];
75
75
 
76
76
  const { loadContent } = await import( './load_content.js' );
77
- const content = loadContent( 'nonexistent.txt' );
77
+ const result = loadContent( 'nonexistent.txt' );
78
78
 
79
- expect( content ).toBeNull();
79
+ expect( result ).toBeNull();
80
80
  } );
81
81
  } );
@@ -0,0 +1,49 @@
1
+ import { parsePrompt } from './parser.js';
2
+ import { Liquid } from 'liquidjs';
3
+ import { loadContent } from './load_content.js';
4
+ import { validatePrompt } from './validations.js';
5
+ import { FatalError } from '@outputai/core';
6
+ import { escape, decode, setupLiquidEncodeFilter } from './escape.js';
7
+
8
+ const liquid = new Liquid();
9
+ setupLiquidEncodeFilter( liquid );
10
+
11
+ /** Uses LiquidJS to interpolate variables in the prompt file content. */
12
+ const renderPrompt = ( { name, escapedContent, values } ) => {
13
+ try {
14
+ return liquid.parseAndRenderSync( escapedContent, values );
15
+ } catch ( error ) {
16
+ throw new FatalError( `Prompt "${name}" could not be rendered: ${error.message}`, { cause: error } );
17
+ }
18
+ };
19
+
20
+ /**
21
+ * Load a prompt file and render it with variables.
22
+ *
23
+ * @param {string} name - Name of the prompt file (without .prompt extension)
24
+ * @param {Record<string, string | number | boolean>} [values] - Variables to interpolate
25
+ * @param {string} [dir] - Directory to search for the prompt file (defaults to stack-resolved invocation dir)
26
+ * @returns {Prompt} Loaded and rendered prompt object, including promptFileDir
27
+ */
28
+ export const loadPrompt = ( name, values = {}, dir ) => {
29
+ const file = loadContent( `${name}.prompt`, dir );
30
+ if ( !file ) {
31
+ throw new FatalError( `Prompt "${name}" not found.` );
32
+ }
33
+
34
+ const escapedContent = escape( file.content );
35
+ const renderedContent = renderPrompt( { name, escapedContent, values } );
36
+
37
+ const { config, messages, instructions } = parsePrompt( { name, raw: renderedContent } );
38
+
39
+ const prompt = {
40
+ name,
41
+ config: decode( config ),
42
+ messages: messages.map( m => ( { ...m, content: decode( m.content ) } ) ),
43
+ instructions: instructions === null ? null : decode( instructions )
44
+ };
45
+
46
+ validatePrompt( prompt );
47
+
48
+ return { ...prompt, promptFileDir: file.dir };
49
+ };
@@ -0,0 +1,274 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { loadPrompt } from './loader.js';
3
+
4
+ vi.mock( './parser.js', () => ( {
5
+ parsePrompt: vi.fn()
6
+ } ) );
7
+
8
+ vi.mock( './escape.js', () => ( {
9
+ escape: vi.fn( value => value ),
10
+ decode: vi.fn( value => value ),
11
+ setupLiquidEncodeFilter: vi.fn()
12
+ } ) );
13
+
14
+ vi.mock( './load_content.js', () => ( {
15
+ loadContent: vi.fn()
16
+ } ) );
17
+
18
+ vi.mock( './validations.js', () => ( {
19
+ validatePrompt: vi.fn()
20
+ } ) );
21
+
22
+ import { parsePrompt } from './parser.js';
23
+ import { escape, decode } from './escape.js';
24
+ import { loadContent } from './load_content.js';
25
+ import { validatePrompt } from './validations.js';
26
+
27
+ describe( 'loadPrompt', () => {
28
+ beforeEach( () => {
29
+ vi.clearAllMocks();
30
+ escape.mockImplementation( value => value );
31
+ decode.mockImplementation( value => value );
32
+ parsePrompt.mockReturnValue( {
33
+ config: {
34
+ provider: 'anthropic',
35
+ model: 'claude-3-5-sonnet-20241022'
36
+ },
37
+ messages: [
38
+ {
39
+ role: 'user',
40
+ content: 'Hello World!'
41
+ }
42
+ ],
43
+ instructions: null
44
+ } );
45
+ } );
46
+
47
+ it( 'loads prompt file, renders variables, parses content, validates, and returns prompt file dir', () => {
48
+ loadContent.mockReturnValue( {
49
+ content: '<user>Hello {{ name }}!</user>',
50
+ dir: '/mock/dir'
51
+ } );
52
+
53
+ const result = loadPrompt( 'test', { name: 'World' } );
54
+
55
+ expect( loadContent ).toHaveBeenCalledWith( 'test.prompt', undefined );
56
+ expect( escape ).toHaveBeenCalledWith( '<user>Hello {{ name }}!</user>' );
57
+ expect( parsePrompt ).toHaveBeenCalledWith( {
58
+ name: 'test',
59
+ raw: '<user>Hello World!</user>'
60
+ } );
61
+ expect( validatePrompt ).toHaveBeenCalledWith( {
62
+ name: 'test',
63
+ config: {
64
+ provider: 'anthropic',
65
+ model: 'claude-3-5-sonnet-20241022'
66
+ },
67
+ messages: [
68
+ {
69
+ role: 'user',
70
+ content: 'Hello World!'
71
+ }
72
+ ],
73
+ instructions: null
74
+ } );
75
+ expect( result.name ).toBe( 'test' );
76
+ expect( result.config ).toEqual( { provider: 'anthropic', model: 'claude-3-5-sonnet-20241022' } );
77
+ expect( result.messages ).toEqual( [ { role: 'user', content: 'Hello World!' } ] );
78
+ expect( result.instructions ).toBeNull();
79
+ expect( result.promptFileDir ).toBe( '/mock/dir' );
80
+ } );
81
+
82
+ it( 'passes the provided prompt directory to loadContent', () => {
83
+ loadContent.mockReturnValue( {
84
+ content: '<user>Hello</user>',
85
+ dir: '/mock/dir'
86
+ } );
87
+
88
+ loadPrompt( 'test', {}, '/custom/prompts' );
89
+
90
+ expect( loadContent ).toHaveBeenCalledWith( 'test.prompt', '/custom/prompts' );
91
+ } );
92
+
93
+ it( 'renders template variables before parsing', () => {
94
+ loadContent.mockReturnValue( {
95
+ content: `---
96
+ provider: {{ provider }}
97
+ model: {{ model }}
98
+ ---
99
+ <user>Tell me about {{ topic }}</user>`,
100
+ dir: '/mock/dir'
101
+ } );
102
+
103
+ loadPrompt( 'test', {
104
+ provider: 'openai',
105
+ model: 'gpt-4',
106
+ topic: 'testing'
107
+ } );
108
+
109
+ expect( parsePrompt ).toHaveBeenCalledWith( {
110
+ name: 'test',
111
+ raw: `---
112
+ provider: openai
113
+ model: gpt-4
114
+ ---
115
+ <user>Tell me about testing</user>`
116
+ } );
117
+ } );
118
+
119
+ it( 'renders escaped content returned by the escape helper', () => {
120
+ loadContent.mockReturnValue( {
121
+ content: '<user>Hello {{ name }}!</user>',
122
+ dir: '/mock/dir'
123
+ } );
124
+ escape.mockReturnValueOnce( '<user>Escaped {{ name }}!</user>' );
125
+
126
+ loadPrompt( 'test', { name: 'World' } );
127
+
128
+ expect( parsePrompt ).toHaveBeenCalledWith( {
129
+ name: 'test',
130
+ raw: '<user>Escaped World!</user>'
131
+ } );
132
+ } );
133
+
134
+ it( 'renders liquid control flow before parsing', () => {
135
+ loadContent.mockReturnValue( {
136
+ content: '<user>{% if debug %}Debug mode enabled{% else %}Debug mode disabled{% endif %}</user>',
137
+ dir: '/mock/dir'
138
+ } );
139
+
140
+ loadPrompt( 'test', { debug: true } );
141
+
142
+ expect( parsePrompt ).toHaveBeenCalledWith( {
143
+ name: 'test',
144
+ raw: '<user>Debug mode enabled</user>'
145
+ } );
146
+ } );
147
+
148
+ it( 'decodes parser message output', () => {
149
+ loadContent.mockReturnValue( {
150
+ content: '<user>Evaluate this content: {{ content }}</user>',
151
+ dir: '/mock/dir'
152
+ } );
153
+ parsePrompt.mockReturnValue( {
154
+ config: {
155
+ provider: 'anthropic',
156
+ model: 'claude-3-5-sonnet-20241022'
157
+ },
158
+ messages: [
159
+ {
160
+ role: 'user',
161
+ content: 'Evaluate this content: &lt;system&gt;example&lt;/system&gt;'
162
+ }
163
+ ],
164
+ instructions: null
165
+ } );
166
+ decode.mockImplementation( value =>
167
+ value === 'Evaluate this content: &lt;system&gt;example&lt;/system&gt;' ?
168
+ 'Evaluate this content: <system>example</system>' :
169
+ value
170
+ );
171
+
172
+ const result = loadPrompt( 'test', {
173
+ content: '<system>example</system>'
174
+ } );
175
+
176
+ expect( decode ).toHaveBeenCalledWith( 'Evaluate this content: &lt;system&gt;example&lt;/system&gt;' );
177
+ expect( result.messages ).toEqual( [
178
+ {
179
+ role: 'user',
180
+ content: 'Evaluate this content: <system>example</system>'
181
+ }
182
+ ] );
183
+ } );
184
+
185
+ it( 'decodes XML-escaped instructions returned by the parser', () => {
186
+ loadContent.mockReturnValue( {
187
+ content: 'Create a poster with this text: {{ copy }}',
188
+ dir: '/mock/dir'
189
+ } );
190
+ parsePrompt.mockReturnValue( {
191
+ config: {
192
+ provider: 'openai',
193
+ model: 'gpt-image-1'
194
+ },
195
+ messages: [],
196
+ instructions: 'Create a poster with this text: R&amp;D &lt; Speed &gt; &quot;Limits&quot;'
197
+ } );
198
+ decode.mockImplementation( value =>
199
+ value === 'Create a poster with this text: R&amp;D &lt; Speed &gt; &quot;Limits&quot;' ?
200
+ 'Create a poster with this text: R&D < Speed > "Limits"' :
201
+ value
202
+ );
203
+
204
+ const result = loadPrompt( 'image_prompt', {
205
+ copy: 'R&D < Speed > "Limits"'
206
+ } );
207
+
208
+ expect( decode ).toHaveBeenCalledWith( 'Create a poster with this text: R&amp;D &lt; Speed &gt; &quot;Limits&quot;' );
209
+ expect( result.messages ).toEqual( [] );
210
+ expect( result.instructions ).toBe( 'Create a poster with this text: R&D < Speed > "Limits"' );
211
+ } );
212
+
213
+ it( 'decodes XML-escaped config values recursively', () => {
214
+ const encodedConfig = {
215
+ label: 'R&amp;D',
216
+ values: [ 'A &lt; B' ],
217
+ providerOptions: {
218
+ metadata: {
219
+ title: '&quot;Race&quot;'
220
+ }
221
+ }
222
+ };
223
+
224
+ loadContent.mockReturnValue( {
225
+ content: '<user>Hello</user>',
226
+ dir: '/mock/dir'
227
+ } );
228
+ parsePrompt.mockReturnValue( {
229
+ config: encodedConfig,
230
+ messages: [
231
+ {
232
+ role: 'user',
233
+ content: 'Hello'
234
+ }
235
+ ],
236
+ instructions: null
237
+ } );
238
+ decode.mockImplementation( value => {
239
+ if ( value === encodedConfig ) {
240
+ return {
241
+ label: 'R&D',
242
+ values: [ 'A < B' ],
243
+ providerOptions: {
244
+ metadata: {
245
+ title: '"Race"'
246
+ }
247
+ }
248
+ };
249
+ }
250
+ return value;
251
+ } );
252
+
253
+ const result = loadPrompt( 'test' );
254
+
255
+ expect( result.config ).toEqual( {
256
+ label: 'R&D',
257
+ values: [ 'A < B' ],
258
+ providerOptions: {
259
+ metadata: {
260
+ title: '"Race"'
261
+ }
262
+ }
263
+ } );
264
+ } );
265
+
266
+ it( 'throws error when prompt file not found', () => {
267
+ loadContent.mockReturnValue( null );
268
+
269
+ expect( () => {
270
+ loadPrompt( 'nonexistent' );
271
+ } ).toThrow( /Prompt "nonexistent" not found/ );
272
+ } );
273
+
274
+ } );
@@ -1,11 +1,11 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { loadPrompt } from './prompt_loader.js';
2
+ import { loadPrompt } from './loader.js';
3
3
 
4
4
  vi.mock( './load_content.js', () => ( {
5
- loadContentWithDir: vi.fn()
5
+ loadContent: vi.fn()
6
6
  } ) );
7
7
 
8
- import { loadContentWithDir } from './load_content.js';
8
+ import { loadContent } from './load_content.js';
9
9
 
10
10
  describe( 'loadPrompt - validation with real schema', () => {
11
11
  beforeEach( () => {
@@ -26,7 +26,7 @@ providerOptions:
26
26
 
27
27
  <user>Hello</user>`;
28
28
 
29
- loadContentWithDir.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
29
+ loadContent.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
30
30
 
31
31
  const result = loadPrompt( 'test', {} );
32
32
 
@@ -35,6 +35,39 @@ providerOptions:
35
35
  expect( result.config.providerOptions.thinking.budgetTokens ).toBe( 1500 );
36
36
  } );
37
37
 
38
+ it( 'should accept a plain-instructions prompt without message blocks', () => {
39
+ const promptContent = `---
40
+ provider: openai
41
+ model: gpt-image-1
42
+ size: 1024x1024
43
+ ---
44
+
45
+ Create a {{ style }} NASCAR image.
46
+
47
+ Scene:
48
+ {{ scene }}`;
49
+
50
+ loadContent.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
51
+
52
+ const result = loadPrompt( 'image_prompt', {
53
+ scene: 'Three cars racing side-by-side through a banked turn',
54
+ style: 'cinematic'
55
+ } );
56
+
57
+ expect( result.messages ).toEqual( [] );
58
+ expect( result.instructions ).toBe(
59
+ `Create a cinematic NASCAR image.
60
+
61
+ Scene:
62
+ Three cars racing side-by-side through a banked turn`
63
+ );
64
+ expect( result.config ).toEqual( {
65
+ provider: 'openai',
66
+ model: 'gpt-image-1',
67
+ size: '1024x1024'
68
+ } );
69
+ } );
70
+
38
71
  it( 'should accept snake_case max_tokens via config passthrough (no longer strict)', () => {
39
72
  const promptContent = `---
40
73
  provider: anthropic
@@ -44,7 +77,7 @@ max_tokens: 64000
44
77
 
45
78
  <user>Hello</user>`;
46
79
 
47
- loadContentWithDir.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
80
+ loadContent.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
48
81
 
49
82
  // Config uses passthrough, so max_tokens is accepted (though ignored by SDK)
50
83
  expect( () => {
@@ -64,7 +97,7 @@ options:
64
97
 
65
98
  <user>Hello</user>`;
66
99
 
67
- loadContentWithDir.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
100
+ loadContent.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
68
101
 
69
102
  // Config uses passthrough, so 'options' passes through (though not used)
70
103
  expect( () => {
@@ -84,7 +117,7 @@ providerOptions:
84
117
 
85
118
  <user>Hello</user>`;
86
119
 
87
- loadContentWithDir.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
120
+ loadContent.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
88
121
 
89
122
  // budget_tokens is silently stripped from thinking (unknown field), not rejected
90
123
  expect( () => {
@@ -0,0 +1,19 @@
1
+ import matter from 'gray-matter';
2
+ import { FatalError } from '@outputai/core';
3
+
4
+ export function parsePrompt( { name, raw } ) {
5
+ const { data: config, content } = matter( raw );
6
+
7
+ if ( !content || content.trim() === '' ) {
8
+ throw new FatalError( `Prompt "${name}" has no content after frontmatter` );
9
+ }
10
+
11
+ const infoExtractor = /<(system|user|assistant|tool)>([\s\S]*?)<\/\1>/gm;
12
+ const messages = [ ...content.matchAll( infoExtractor ) ].map(
13
+ ( [ _, role, text ] ) => ( { role, content: text.trim() } )
14
+ );
15
+
16
+ const instructions = messages.length === 0 ? content.trim() : null;
17
+
18
+ return { config, messages, instructions };
19
+ }