@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.
- package/package.json +2 -2
- package/src/agent.js +15 -9
- package/src/agent.spec.js +295 -214
- package/src/ai_model.js +79 -36
- package/src/ai_model.spec.js +31 -13
- package/src/ai_sdk.js +55 -79
- package/src/ai_sdk.spec.js +464 -611
- package/src/ai_sdk_options.js +61 -0
- package/src/ai_sdk_options.spec.js +164 -0
- package/src/cost/index.js +1 -1
- package/src/index.d.ts +230 -175
- package/src/index.js +2 -2
- package/src/prompt/escape.js +65 -0
- package/src/prompt/escape.spec.js +159 -0
- package/src/{load_content.js → prompt/load_content.js} +1 -22
- package/src/{load_content.spec.js → prompt/load_content.spec.js} +6 -6
- package/src/prompt/loader.js +49 -0
- package/src/prompt/loader.spec.js +274 -0
- package/src/{prompt_loader_validation.spec.js → prompt/loader_validation.spec.js} +40 -7
- package/src/prompt/parser.js +19 -0
- package/src/{parser.spec.js → prompt/parser.spec.js} +74 -29
- package/src/prompt/prepare_text.js +27 -0
- package/src/prompt/prepare_text.spec.js +141 -0
- package/src/{skill.js → prompt/skill.js} +19 -0
- package/src/prompt/skill.spec.js +172 -0
- package/src/{prompt_validations.js → prompt/validations.js} +32 -6
- package/src/{prompt_validations.spec.js → prompt/validations.spec.js} +189 -1
- package/src/utils/__fixtures__/image_response.json +38 -0
- package/src/utils/__fixtures__/stream_response.json +294 -0
- package/src/utils/__fixtures__/text_response.json +201 -0
- package/src/utils/error_handler.js +65 -0
- package/src/utils/error_handler.spec.js +195 -0
- package/src/utils/image.js +10 -0
- package/src/utils/image.spec.js +20 -0
- package/src/utils/response_wrappers.js +46 -19
- package/src/utils/response_wrappers.spec.js +130 -70
- package/src/utils/source_extraction.js +17 -27
- package/src/utils/trace.js +2 -3
- package/src/utils/trace.spec.js +9 -13
- package/src/validations.js +54 -2
- package/src/validations.spec.js +166 -0
- package/src/parser.js +0 -28
- package/src/prompt_loader.js +0 -80
- package/src/prompt_loader.spec.js +0 -358
- 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 <', () => {
|
|
7
|
+
expect( encodeFilter( '<' ) ).toBe( '<' );
|
|
8
|
+
} );
|
|
9
|
+
|
|
10
|
+
it( 'encodes > to >', () => {
|
|
11
|
+
expect( encodeFilter( '>' ) ).toBe( '>' );
|
|
12
|
+
} );
|
|
13
|
+
|
|
14
|
+
it( 'encodes & to &', () => {
|
|
15
|
+
expect( encodeFilter( '&' ) ).toBe( '&' );
|
|
16
|
+
} );
|
|
17
|
+
|
|
18
|
+
it( 'encodes a string with multiple special characters in one pass', () => {
|
|
19
|
+
expect( encodeFilter( '<a & b>' ) ).toBe( '<a & b>' );
|
|
20
|
+
} );
|
|
21
|
+
|
|
22
|
+
it( 'encodes a tag-shaped substring so the parser cannot tokenize it', () => {
|
|
23
|
+
expect( encodeFilter( '<system>x</system>' ) ).toBe( '<system>x</system>' );
|
|
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( '<system>x</system>' );
|
|
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&D < Speed > "Limits"' ) ).toBe( 'R&D < Speed > "Limits"' );
|
|
135
|
+
} );
|
|
136
|
+
|
|
137
|
+
it( 'decodes XML entities recursively in arrays and plain objects', () => {
|
|
138
|
+
expect( decode( {
|
|
139
|
+
label: 'R&D',
|
|
140
|
+
values: [ 'A < B' ],
|
|
141
|
+
nested: {
|
|
142
|
+
title: '"Race"'
|
|
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
|
|
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
|
|
48
|
+
const result = loadContent( 'test.txt' );
|
|
49
49
|
|
|
50
|
-
expect(
|
|
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
|
|
66
|
+
const result = loadContent( 'nested.txt' );
|
|
67
67
|
|
|
68
|
-
expect(
|
|
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
|
|
77
|
+
const result = loadContent( 'nonexistent.txt' );
|
|
78
78
|
|
|
79
|
-
expect(
|
|
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: <system>example</system>'
|
|
162
|
+
}
|
|
163
|
+
],
|
|
164
|
+
instructions: null
|
|
165
|
+
} );
|
|
166
|
+
decode.mockImplementation( value =>
|
|
167
|
+
value === 'Evaluate this content: <system>example</system>' ?
|
|
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: <system>example</system>' );
|
|
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&D < Speed > "Limits"'
|
|
197
|
+
} );
|
|
198
|
+
decode.mockImplementation( value =>
|
|
199
|
+
value === 'Create a poster with this text: R&D < Speed > "Limits"' ?
|
|
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&D < Speed > "Limits"' );
|
|
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&D',
|
|
216
|
+
values: [ 'A < B' ],
|
|
217
|
+
providerOptions: {
|
|
218
|
+
metadata: {
|
|
219
|
+
title: '"Race"'
|
|
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 './
|
|
2
|
+
import { loadPrompt } from './loader.js';
|
|
3
3
|
|
|
4
4
|
vi.mock( './load_content.js', () => ( {
|
|
5
|
-
|
|
5
|
+
loadContent: vi.fn()
|
|
6
6
|
} ) );
|
|
7
7
|
|
|
8
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|