@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,361 +1,258 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
|
|
3
|
-
|
|
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( '
|
|
6
|
-
|
|
23
|
+
vi.mock( 'gray-matter', () => ( {
|
|
24
|
+
default: vi.fn()
|
|
7
25
|
} ) );
|
|
8
26
|
|
|
9
|
-
vi.mock( '
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
27
|
+
vi.mock( '@outputai/core/sdk/helpers', () => ( {
|
|
28
|
+
Path: {
|
|
29
|
+
resolveInvocationDir: vi.fn( () => '/invocation' )
|
|
30
|
+
}
|
|
13
31
|
} ) );
|
|
14
32
|
|
|
15
|
-
vi.mock( './
|
|
16
|
-
|
|
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
|
-
|
|
49
|
+
parsePromptSchema: vi.fn( prompt => prompt )
|
|
21
50
|
} ) );
|
|
22
51
|
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import
|
|
26
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
dir: '/mock/dir'
|
|
52
|
-
} );
|
|
92
|
+
afterEach( () => {
|
|
93
|
+
vi.restoreAllMocks();
|
|
94
|
+
} );
|
|
53
95
|
|
|
54
|
-
|
|
96
|
+
it( 'splits, renders, parses, and validates the prompt', () => {
|
|
97
|
+
const variables = { name: 'World' };
|
|
55
98
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
expect(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
} );
|
|
62
|
-
expect(
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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( '
|
|
84
|
-
|
|
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',
|
|
122
|
+
loadPrompt( 'test', variables );
|
|
90
123
|
|
|
91
|
-
|
|
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( '
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
132
|
+
it( 'uses decoded frontmatter for content parsing and validation', () => {
|
|
133
|
+
const encodedConfig = {
|
|
134
|
+
...defaultConfig,
|
|
135
|
+
messageOptions: { cached: { openai: { label: 'R&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(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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( '
|
|
121
|
-
|
|
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(
|
|
130
|
-
|
|
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
|
|
136
|
-
|
|
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', {
|
|
163
|
+
loadPrompt( 'test', { name: 'World' } );
|
|
142
164
|
|
|
143
|
-
expect(
|
|
144
|
-
|
|
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( '
|
|
150
|
-
|
|
151
|
-
|
|
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: <system>example</system>'
|
|
163
|
-
}
|
|
164
|
-
],
|
|
165
|
-
instructions: null
|
|
166
|
-
} );
|
|
167
|
-
decode.mockImplementation( value =>
|
|
168
|
-
value === 'Evaluate this content: <system>example</system>' ?
|
|
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
|
-
|
|
174
|
-
content: '<system>example</system>'
|
|
175
|
-
} );
|
|
173
|
+
loadPrompt( 'test' );
|
|
176
174
|
|
|
177
|
-
expect(
|
|
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( '
|
|
187
|
-
|
|
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: '
|
|
181
|
+
instructions: 'Generate a poster.'
|
|
198
182
|
} );
|
|
199
|
-
decode.mockImplementation( value =>
|
|
200
|
-
value === 'Create a poster with this text: R&D < Speed > "Limits"' ?
|
|
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&D < Speed > "Limits"' );
|
|
210
186
|
expect( result.messages ).toEqual( [] );
|
|
211
|
-
expect( result.instructions ).toBe( '
|
|
187
|
+
expect( result.instructions ).toBe( 'Generate a poster.' );
|
|
212
188
|
} );
|
|
213
189
|
|
|
214
|
-
it( '
|
|
215
|
-
|
|
216
|
-
label: 'R&D',
|
|
217
|
-
values: [ 'A < B' ],
|
|
218
|
-
providerOptions: {
|
|
219
|
-
metadata: {
|
|
220
|
-
title: '"Race"'
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
};
|
|
190
|
+
it( 'throws when the prompt file is not found', () => {
|
|
191
|
+
searchAndReadFile.mockReturnValue( null );
|
|
224
192
|
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
196
|
+
it( 'throws when rendered content is empty', () => {
|
|
197
|
+
liquidMocks.parseAndRenderSync
|
|
198
|
+
.mockReturnValueOnce( 'RAW_YAML' )
|
|
199
|
+
.mockReturnValueOnce( ' ' );
|
|
255
200
|
|
|
256
|
-
expect(
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
209
|
+
expect( () => loadPrompt( 'writer@v1' ) ).toThrow(
|
|
210
|
+
/Error parsing frontmatter on prompt "writer@v1": bad delimiters/
|
|
211
|
+
);
|
|
212
|
+
} );
|
|
286
213
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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( '
|
|
298
|
-
|
|
299
|
-
|
|
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(
|
|
321
|
-
|
|
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( '
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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( '
|
|
354
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
+
};
|