@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,361 +1,258 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { Logger } from '@outputai/core';
3
- import { loadPrompt } from './loader.js';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ const liquidMocks = vi.hoisted( () => ( {
4
+ createContext: vi.fn(),
5
+ options: { strictVariables: true },
6
+ parseAndRenderSync: vi.fn(),
7
+ registerFilter: vi.fn()
8
+ } ) );
9
+
10
+ vi.mock( 'liquidjs', () => ( {
11
+ Context: class Context {
12
+ constructor( ...args ) {
13
+ liquidMocks.createContext( this, ...args );
14
+ }
15
+ },
16
+ Liquid: class Liquid {
17
+ options = liquidMocks.options;
18
+ parseAndRenderSync = liquidMocks.parseAndRenderSync;
19
+ registerFilter = liquidMocks.registerFilter;
20
+ }
21
+ } ) );
4
22
 
5
- vi.mock( './parser.js', () => ( {
6
- parsePrompt: vi.fn()
23
+ vi.mock( 'gray-matter', () => ( {
24
+ default: vi.fn()
7
25
  } ) );
8
26
 
9
- vi.mock( './escape.js', () => ( {
10
- escape: vi.fn( value => value ),
11
- decode: vi.fn( value => value ),
12
- setupLiquidEncodeFilter: vi.fn()
27
+ vi.mock( '@outputai/core/sdk/helpers', () => ( {
28
+ Path: {
29
+ resolveInvocationDir: vi.fn( () => '/invocation' )
30
+ }
13
31
  } ) );
14
32
 
15
- vi.mock( './load_content.js', () => ( {
16
- loadContent: vi.fn()
33
+ vi.mock( './interpolations.js', () => ( {
34
+ pipeInterpolations: vi.fn( value => value ),
35
+ interpolationFilterToken: '__var_safe',
36
+ encode: vi.fn(),
37
+ decode: vi.fn( value => value )
38
+ } ) );
39
+
40
+ vi.mock( '../utils/file.js', () => ( {
41
+ searchAndReadFile: vi.fn()
42
+ } ) );
43
+
44
+ vi.mock( './content.js', () => ( {
45
+ parseContent: vi.fn()
17
46
  } ) );
18
47
 
19
48
  vi.mock( './validations.js', () => ( {
20
- validatePrompt: vi.fn()
49
+ parsePromptSchema: vi.fn( prompt => prompt )
21
50
  } ) );
22
51
 
23
- import { parsePrompt } from './parser.js';
24
- import { escape, decode } from './escape.js';
25
- import { loadContent } from './load_content.js';
26
- import { validatePrompt } from './validations.js';
52
+ import { loadPrompt } from './loader.js';
53
+ import { Path } from '@outputai/core/sdk/helpers';
54
+ import matter from 'gray-matter';
55
+ import { decode, pipeInterpolations } from './interpolations.js';
56
+ import { searchAndReadFile } from '../utils/file.js';
57
+ import { parseContent } from './content.js';
58
+ import { parsePromptSchema } from './validations.js';
59
+
60
+ const defaultConfig = {
61
+ provider: 'anthropic',
62
+ model: 'claude-3-5-sonnet-20241022'
63
+ };
64
+
65
+ const defaultParsed = {
66
+ messages: [ { role: 'user', content: 'Hi' } ],
67
+ instructions: null
68
+ };
69
+
70
+ const stubMatter = ( config = defaultConfig ) => {
71
+ matter.mockImplementation( ( _input, options ) => {
72
+ if ( options?.engines ) {
73
+ return { matter: 'RAW_YAML', content: 'RAW_BODY' };
74
+ }
75
+ return { data: { ...config } };
76
+ } );
77
+ };
27
78
 
28
79
  describe( 'loadPrompt', () => {
29
80
  beforeEach( () => {
30
81
  vi.clearAllMocks();
31
- escape.mockImplementation( value => value );
82
+ Path.resolveInvocationDir.mockReturnValue( '/invocation' );
83
+ searchAndReadFile.mockReturnValue( { content: 'FILE', dir: '/mock/dir' } );
84
+ pipeInterpolations.mockImplementation( value => value );
32
85
  decode.mockImplementation( value => value );
33
- parsePrompt.mockReturnValue( {
34
- config: {
35
- provider: 'anthropic',
36
- model: 'claude-3-5-sonnet-20241022'
37
- },
38
- messages: [
39
- {
40
- role: 'user',
41
- content: 'Hello World!'
42
- }
43
- ],
44
- instructions: null
45
- } );
86
+ liquidMocks.parseAndRenderSync.mockImplementation( template => template );
87
+ parseContent.mockReturnValue( { ...defaultParsed, messages: [ ...defaultParsed.messages ] } );
88
+ parsePromptSchema.mockImplementation( prompt => prompt );
89
+ stubMatter();
46
90
  } );
47
91
 
48
- it( 'loads prompt file, renders variables, parses content, validates, and returns prompt file dir', () => {
49
- loadContent.mockReturnValue( {
50
- content: '<user>Hello {{ name }}!</user>',
51
- dir: '/mock/dir'
52
- } );
92
+ afterEach( () => {
93
+ vi.restoreAllMocks();
94
+ } );
53
95
 
54
- const result = loadPrompt( 'test', { name: 'World' } );
96
+ it( 'splits, renders, parses, and validates the prompt', () => {
97
+ const variables = { name: 'World' };
55
98
 
56
- expect( loadContent ).toHaveBeenCalledWith( 'test.prompt', undefined );
57
- expect( escape ).toHaveBeenCalledWith( '<user>Hello {{ name }}!</user>' );
58
- expect( parsePrompt ).toHaveBeenCalledWith( {
59
- name: 'test',
60
- raw: '<user>Hello World!</user>'
61
- } );
62
- expect( validatePrompt ).toHaveBeenCalledWith( {
99
+ const result = loadPrompt( 'test', variables );
100
+
101
+ expect( searchAndReadFile ).toHaveBeenCalledWith( '/invocation', 'test.prompt' );
102
+ expect( matter ).toHaveBeenNthCalledWith( 1, 'FILE', expect.objectContaining( {
103
+ engines: { yaml: expect.any( Function ) }
104
+ } ) );
105
+ expect( matter ).toHaveBeenNthCalledWith( 2, '---\nRAW_YAML\n---\n' );
106
+ expect( decode ).toHaveBeenCalledWith( defaultConfig );
107
+ expect( parseContent ).toHaveBeenCalledWith( 'RAW_BODY', undefined );
108
+ expect( parsePromptSchema ).toHaveBeenCalledWith( {
63
109
  name: 'test',
64
- config: {
65
- provider: 'anthropic',
66
- model: 'claude-3-5-sonnet-20241022'
67
- },
68
- messages: [
69
- {
70
- role: 'user',
71
- content: 'Hello World!'
72
- }
73
- ],
74
- instructions: null
110
+ config: defaultConfig,
111
+ messages: defaultParsed.messages,
112
+ instructions: null,
113
+ fileDir: '/mock/dir',
114
+ variables
75
115
  } );
76
- expect( result.name ).toBe( 'test' );
77
- expect( result.config ).toEqual( { provider: 'anthropic', model: 'claude-3-5-sonnet-20241022' } );
78
- expect( result.messages ).toEqual( [ { role: 'user', content: 'Hello World!' } ] );
79
- expect( result.instructions ).toBeNull();
80
- expect( result.promptFileDir ).toBe( '/mock/dir' );
116
+ expect( result ).toEqual( parsePromptSchema.mock.calls[0][0] );
81
117
  } );
82
118
 
83
- it( 'passes the provided prompt directory to loadContent', () => {
84
- loadContent.mockReturnValue( {
85
- content: '<user>Hello</user>',
86
- dir: '/mock/dir'
87
- } );
119
+ it( 'renders frontmatter before content using one Liquid context', () => {
120
+ const variables = { name: 'World' };
88
121
 
89
- loadPrompt( 'test', {}, '/custom/prompts' );
122
+ loadPrompt( 'test', variables );
90
123
 
91
- expect( loadContent ).toHaveBeenCalledWith( 'test.prompt', '/custom/prompts' );
124
+ const context = liquidMocks.createContext.mock.calls[0][0];
125
+ expect( liquidMocks.createContext ).toHaveBeenCalledWith( context, variables, liquidMocks.options, { sync: true } );
126
+ expect( pipeInterpolations ).toHaveBeenNthCalledWith( 1, 'RAW_YAML' );
127
+ expect( pipeInterpolations ).toHaveBeenNthCalledWith( 2, 'RAW_BODY' );
128
+ expect( liquidMocks.parseAndRenderSync ).toHaveBeenNthCalledWith( 1, 'RAW_YAML', context );
129
+ expect( liquidMocks.parseAndRenderSync ).toHaveBeenNthCalledWith( 2, 'RAW_BODY', context );
92
130
  } );
93
131
 
94
- it( 'renders template variables before parsing', () => {
95
- loadContent.mockReturnValue( {
96
- content: `---
97
- provider: {{ provider }}
98
- model: {{ model }}
99
- ---
100
- <user>Tell me about {{ topic }}</user>`,
101
- dir: '/mock/dir'
102
- } );
132
+ it( 'uses decoded frontmatter for content parsing and validation', () => {
133
+ const encodedConfig = {
134
+ ...defaultConfig,
135
+ messageOptions: { cached: { openai: { label: 'R&amp;D' } } }
136
+ };
137
+ const decodedConfig = {
138
+ ...defaultConfig,
139
+ messageOptions: { cached: { openai: { label: 'R&D' } } }
140
+ };
141
+ stubMatter( encodedConfig );
142
+ decode.mockReturnValue( decodedConfig );
103
143
 
104
- loadPrompt( 'test', {
105
- provider: 'openai',
106
- model: 'gpt-4',
107
- topic: 'testing'
108
- } );
144
+ loadPrompt( 'test' );
109
145
 
110
- expect( parsePrompt ).toHaveBeenCalledWith( {
111
- name: 'test',
112
- raw: `---
113
- provider: openai
114
- model: gpt-4
115
- ---
116
- <user>Tell me about testing</user>`
117
- } );
146
+ expect( decode ).toHaveBeenCalledWith( encodedConfig );
147
+ expect( parseContent ).toHaveBeenCalledWith( 'RAW_BODY', decodedConfig.messageOptions );
148
+ expect( parsePromptSchema ).toHaveBeenCalledWith( expect.objectContaining( {
149
+ config: decodedConfig
150
+ } ) );
118
151
  } );
119
152
 
120
- it( 'renders escaped content returned by the escape helper', () => {
121
- loadContent.mockReturnValue( {
122
- content: '<user>Hello {{ name }}!</user>',
123
- dir: '/mock/dir'
124
- } );
125
- escape.mockReturnValueOnce( '<user>Escaped {{ name }}!</user>' );
126
-
127
- loadPrompt( 'test', { name: 'World' } );
153
+ it( 'passes the provided prompt directory to searchAndReadFile', () => {
154
+ loadPrompt( 'test', {}, '/custom/prompts' );
128
155
 
129
- expect( parsePrompt ).toHaveBeenCalledWith( {
130
- name: 'test',
131
- raw: '<user>Escaped World!</user>'
132
- } );
156
+ expect( searchAndReadFile ).toHaveBeenCalledWith( '/custom/prompts', 'test.prompt' );
157
+ expect( Path.resolveInvocationDir ).not.toHaveBeenCalled();
133
158
  } );
134
159
 
135
- it( 'renders liquid control flow before parsing', () => {
136
- loadContent.mockReturnValue( {
137
- content: '<user>{% if debug %}Debug mode enabled{% else %}Debug mode disabled{% endif %}</user>',
138
- dir: '/mock/dir'
139
- } );
160
+ it( 'renders the piped template, not the raw body', () => {
161
+ pipeInterpolations.mockReturnValue( 'PIPED' );
140
162
 
141
- loadPrompt( 'test', { debug: true } );
163
+ loadPrompt( 'test', { name: 'World' } );
142
164
 
143
- expect( parsePrompt ).toHaveBeenCalledWith( {
144
- name: 'test',
145
- raw: '<user>Debug mode enabled</user>'
146
- } );
165
+ expect( liquidMocks.parseAndRenderSync ).toHaveBeenNthCalledWith( 2, 'PIPED', expect.any( Object ) );
166
+ expect( parseContent ).toHaveBeenCalledWith( 'PIPED', undefined );
147
167
  } );
148
168
 
149
- it( 'decodes parser message output', () => {
150
- loadContent.mockReturnValue( {
151
- content: '<user>Evaluate this content: {{ content }}</user>',
152
- dir: '/mock/dir'
153
- } );
154
- parsePrompt.mockReturnValue( {
155
- config: {
156
- provider: 'anthropic',
157
- model: 'claude-3-5-sonnet-20241022'
158
- },
159
- messages: [
160
- {
161
- role: 'user',
162
- content: 'Evaluate this content: &lt;system&gt;example&lt;/system&gt;'
163
- }
164
- ],
165
- instructions: null
166
- } );
167
- decode.mockImplementation( value =>
168
- value === 'Evaluate this content: &lt;system&gt;example&lt;/system&gt;' ?
169
- 'Evaluate this content: <system>example</system>' :
170
- value
171
- );
169
+ it( 'passes config.messageOptions to parseContent', () => {
170
+ const messageOptions = { cached: { anthropic: { cacheControl: { type: 'ephemeral' } } } };
171
+ stubMatter( { ...defaultConfig, messageOptions } );
172
172
 
173
- const result = loadPrompt( 'test', {
174
- content: '<system>example</system>'
175
- } );
173
+ loadPrompt( 'test' );
176
174
 
177
- expect( decode ).toHaveBeenCalledWith( 'Evaluate this content: &lt;system&gt;example&lt;/system&gt;' );
178
- expect( result.messages ).toEqual( [
179
- {
180
- role: 'user',
181
- content: 'Evaluate this content: <system>example</system>'
182
- }
183
- ] );
175
+ expect( parseContent ).toHaveBeenCalledWith( 'RAW_BODY', messageOptions );
184
176
  } );
185
177
 
186
- it( 'decodes XML-escaped instructions returned by the parser', () => {
187
- loadContent.mockReturnValue( {
188
- content: 'Create a poster with this text: {{ copy }}',
189
- dir: '/mock/dir'
190
- } );
191
- parsePrompt.mockReturnValue( {
192
- config: {
193
- provider: 'openai',
194
- model: 'gpt-image-1'
195
- },
178
+ it( 'uses parseContent for messages and instructions', () => {
179
+ parseContent.mockReturnValue( {
196
180
  messages: [],
197
- instructions: 'Create a poster with this text: R&amp;D &lt; Speed &gt; &quot;Limits&quot;'
181
+ instructions: 'Generate a poster.'
198
182
  } );
199
- decode.mockImplementation( value =>
200
- value === 'Create a poster with this text: R&amp;D &lt; Speed &gt; &quot;Limits&quot;' ?
201
- 'Create a poster with this text: R&D < Speed > "Limits"' :
202
- value
203
- );
204
183
 
205
- const result = loadPrompt( 'image_prompt', {
206
- copy: 'R&D < Speed > "Limits"'
207
- } );
184
+ const result = loadPrompt( 'image_prompt' );
208
185
 
209
- expect( decode ).toHaveBeenCalledWith( 'Create a poster with this text: R&amp;D &lt; Speed &gt; &quot;Limits&quot;' );
210
186
  expect( result.messages ).toEqual( [] );
211
- expect( result.instructions ).toBe( 'Create a poster with this text: R&D < Speed > "Limits"' );
187
+ expect( result.instructions ).toBe( 'Generate a poster.' );
212
188
  } );
213
189
 
214
- it( 'decodes XML-escaped config values recursively', () => {
215
- const encodedConfig = {
216
- label: 'R&amp;D',
217
- values: [ 'A &lt; B' ],
218
- providerOptions: {
219
- metadata: {
220
- title: '&quot;Race&quot;'
221
- }
222
- }
223
- };
190
+ it( 'throws when the prompt file is not found', () => {
191
+ searchAndReadFile.mockReturnValue( null );
224
192
 
225
- loadContent.mockReturnValue( {
226
- content: '<user>Hello</user>',
227
- dir: '/mock/dir'
228
- } );
229
- parsePrompt.mockReturnValue( {
230
- config: encodedConfig,
231
- messages: [
232
- {
233
- role: 'user',
234
- content: 'Hello'
235
- }
236
- ],
237
- instructions: null
238
- } );
239
- decode.mockImplementation( value => {
240
- if ( value === encodedConfig ) {
241
- return {
242
- label: 'R&D',
243
- values: [ 'A < B' ],
244
- providerOptions: {
245
- metadata: {
246
- title: '"Race"'
247
- }
248
- }
249
- };
250
- }
251
- return value;
252
- } );
193
+ expect( () => loadPrompt( 'nonexistent' ) ).toThrow( /Prompt file "nonexistent" not found/ );
194
+ } );
253
195
 
254
- const result = loadPrompt( 'test' );
196
+ it( 'throws when rendered content is empty', () => {
197
+ liquidMocks.parseAndRenderSync
198
+ .mockReturnValueOnce( 'RAW_YAML' )
199
+ .mockReturnValueOnce( ' ' );
255
200
 
256
- expect( result.config ).toEqual( {
257
- label: 'R&D',
258
- values: [ 'A < B' ],
259
- providerOptions: {
260
- metadata: {
261
- title: '"Race"'
262
- }
263
- }
264
- } );
201
+ expect( () => loadPrompt( 'empty_prompt' ) ).toThrow( /Prompt "empty_prompt" has no content/ );
265
202
  } );
266
203
 
267
- it.each( [
268
- [ 'vertex', 'google-vertex' ],
269
- [ 'bedrock', 'amazon-bedrock' ]
270
- ] )( 'rewrites deprecated provider alias %s to %s and warns', ( alias, canonical ) => {
271
- const warn = vi.spyOn( Logger, 'warn' ).mockImplementation( () => {} );
272
- loadContent.mockReturnValue( {
273
- content: '<user>Hello</user>',
274
- dir: '/mock/dir'
275
- } );
276
- parsePrompt.mockReturnValue( {
277
- config: {
278
- provider: alias,
279
- model: 'test-model'
280
- },
281
- messages: [ { role: 'user', content: 'Hello' } ],
282
- instructions: null
204
+ it( 'wraps split errors with the prompt name', () => {
205
+ matter.mockImplementation( () => {
206
+ throw new Error( 'bad delimiters' );
283
207
  } );
284
208
 
285
- const result = loadPrompt( 'test' );
209
+ expect( () => loadPrompt( 'writer@v1' ) ).toThrow(
210
+ /Error parsing frontmatter on prompt "writer@v1": bad delimiters/
211
+ );
212
+ } );
286
213
 
287
- expect( result.config.provider ).toBe( canonical );
288
- expect( validatePrompt ).toHaveBeenCalledWith( expect.objectContaining( {
289
- config: expect.objectContaining( { provider: canonical } )
290
- } ) );
291
- expect( warn ).toHaveBeenCalledWith(
292
- `Using deprecated provider alias "${alias}". Use "${canonical}" instead.`,
293
- { namespace: 'LLM' }
214
+ it( 'wraps content render errors with the prompt name', () => {
215
+ liquidMocks.parseAndRenderSync
216
+ .mockReturnValueOnce( 'RAW_YAML' )
217
+ .mockImplementationOnce( () => {
218
+ throw new Error( 'unknown variable' );
219
+ } );
220
+
221
+ expect( () => loadPrompt( 'writer@v1' ) ).toThrow(
222
+ /Error rendering content on prompt "writer@v1": unknown variable/
294
223
  );
295
224
  } );
296
225
 
297
- it( 'rewrites provider aliases on the decoded config', () => {
298
- const warn = vi.spyOn( Logger, 'warn' ).mockImplementation( () => {} );
299
- const parsedConfig = {
300
- provider: 'bedrock',
301
- model: 'test-model'
302
- };
303
- const decodedConfig = {
304
- provider: 'bedrock',
305
- model: 'test-model'
306
- };
307
- loadContent.mockReturnValue( {
308
- content: '<user>Hello</user>',
309
- dir: '/mock/dir'
226
+ it( 'wraps frontmatter render errors with the prompt name', () => {
227
+ liquidMocks.parseAndRenderSync.mockImplementationOnce( () => {
228
+ throw new Error( 'unknown filter' );
310
229
  } );
311
- parsePrompt.mockReturnValue( {
312
- config: parsedConfig,
313
- messages: [ { role: 'user', content: 'Hello' } ],
314
- instructions: null
315
- } );
316
- decode.mockImplementation( value => ( value === parsedConfig ? decodedConfig : value ) );
317
-
318
- const result = loadPrompt( 'test' );
319
230
 
320
- expect( decode ).toHaveBeenCalledWith( parsedConfig );
321
- expect( result.config ).toBe( decodedConfig );
322
- expect( result.config.provider ).toBe( 'amazon-bedrock' );
323
- expect( validatePrompt ).toHaveBeenCalledWith( expect.objectContaining( {
324
- config: decodedConfig
325
- } ) );
326
- expect( warn ).toHaveBeenCalledWith(
327
- 'Using deprecated provider alias "bedrock". Use "amazon-bedrock" instead.',
328
- { namespace: 'LLM' }
231
+ expect( () => loadPrompt( 'writer@v1' ) ).toThrow(
232
+ /Error rendering frontmatter on prompt "writer@v1": unknown filter/
329
233
  );
330
234
  } );
331
235
 
332
- it( 'leaves canonical provider names unchanged', () => {
333
- const warn = vi.spyOn( Logger, 'warn' ).mockImplementation( () => {} );
334
- loadContent.mockReturnValue( {
335
- content: '<user>Hello</user>',
336
- dir: '/mock/dir'
337
- } );
338
- parsePrompt.mockReturnValue( {
339
- config: {
340
- provider: 'google-vertex',
341
- model: 'gemini-2.5-flash-lite'
342
- },
343
- messages: [ { role: 'user', content: 'Hello' } ],
344
- instructions: null
236
+ it( 'wraps frontmatter yaml parse errors with the prompt name', () => {
237
+ matter.mockImplementation( ( _input, options ) => {
238
+ if ( options?.engines ) {
239
+ return { matter: 'RAW_YAML', content: 'RAW_BODY' };
240
+ }
241
+ throw new Error( 'invalid yaml' );
345
242
  } );
346
243
 
347
- const result = loadPrompt( 'test' );
348
-
349
- expect( result.config.provider ).toBe( 'google-vertex' );
350
- expect( warn ).not.toHaveBeenCalled();
244
+ expect( () => loadPrompt( 'writer@v1' ) ).toThrow(
245
+ /Error converting frontmatter yaml to js on prompt "writer@v1": invalid yaml/
246
+ );
351
247
  } );
352
248
 
353
- it( 'throws error when prompt file not found', () => {
354
- loadContent.mockReturnValue( null );
249
+ it( 'wraps content parse errors with the prompt name', () => {
250
+ parseContent.mockImplementation( () => {
251
+ throw new Error( 'unknown option' );
252
+ } );
355
253
 
356
- expect( () => {
357
- loadPrompt( 'nonexistent' );
358
- } ).toThrow( /Prompt "nonexistent" not found/ );
254
+ expect( () => loadPrompt( 'writer@v1' ) ).toThrow(
255
+ /Error parsing content on prompt "writer@v1": unknown option/
256
+ );
359
257
  } );
360
-
361
258
  } );
@@ -0,0 +1,52 @@
1
+ import { ValidationError } from '@outputai/core';
2
+
3
+ const attributeSeparator = /(?<![=\s])\s+(?!\s*=)(?=(?:[^"']|"[^"]*"|'[^']*')*$)/;
4
+
5
+ export const extractAttributes = rawAttributes => {
6
+ if ( !rawAttributes?.trim() ) {
7
+ return {};
8
+ }
9
+ const pairs = rawAttributes.trim().split( attributeSeparator );
10
+ const attributeKeyRule = /^[A-Za-z][\w-]*$/;
11
+
12
+ return Object.fromEntries(
13
+ pairs.map( p => {
14
+ const splitIndex = p.indexOf( '=' );
15
+ if ( splitIndex === -1 ) {
16
+ const key = p.toLowerCase();
17
+ if ( !attributeKeyRule.test( key ) ) {
18
+ throw new ValidationError( `Invalid attribute name "${key}".` );
19
+ }
20
+ return [ key, true ];
21
+ }
22
+
23
+ const key = p.slice( 0, splitIndex ).trim().toLowerCase();
24
+ if ( !key ) {
25
+ throw new ValidationError( `Invalid attribute "${p}": name is missing.` );
26
+ }
27
+ if ( !attributeKeyRule.test( key ) ) {
28
+ throw new ValidationError( `Invalid attribute name "${key}".` );
29
+ }
30
+ const value = p.slice( splitIndex + 1 ).trim();
31
+ const openingQuote = value[0];
32
+ const isQuoted = openingQuote === '\'' || openingQuote === '"';
33
+
34
+ if ( !isQuoted ) {
35
+ const invalidCharacter = value.match( /['"<>=]/ )?.[0];
36
+ if ( invalidCharacter ) {
37
+ throw new ValidationError( `Invalid attribute "${key}": unquoted value contains invalid character \`${invalidCharacter}\`.` );
38
+ }
39
+ return [ key, value ];
40
+ } else {
41
+ if ( value.at( -1 ) !== openingQuote ) {
42
+ throw new ValidationError( `Invalid attribute "${key}": quoted value is missing its closing quote (${openingQuote}).` );
43
+ }
44
+ const unquoted = value.slice( 1, -1 );
45
+ if ( unquoted.includes( openingQuote ) ) {
46
+ throw new ValidationError( `Invalid attribute "${key}": quoted value contains an unexpected quote (${openingQuote}).` );
47
+ }
48
+ return [ key, unquoted ];
49
+ }
50
+ } )
51
+ );
52
+ };