@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
package/src/prompt/blocks.js
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Roles that introduce a message block. Add a role here to support a new
|
|
3
|
-
* `<role>...</role>` block — the tokenizer pattern is derived from this set,
|
|
4
|
-
* so no other parser change is required.
|
|
5
|
-
*/
|
|
6
|
-
export const BLOCK_ROLES = new Set( [ 'system', 'user', 'assistant', 'tool' ] );
|
|
7
|
-
|
|
8
|
-
const BLOCK_PATTERN = new RegExp(
|
|
9
|
-
`<(${[ ...BLOCK_ROLES ].join( '|' )})((?:\\s[^>]*)?)>([\\s\\S]*?)<\\/\\1>`,
|
|
10
|
-
'gm'
|
|
11
|
-
);
|
|
12
|
-
|
|
13
|
-
const ATTRIBUTE_PATTERN = /([a-zA-Z][\w-]*)(?:=(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Parse a raw opening-tag attribute string into a plain object. Supports bare booleans
|
|
17
|
-
* (`cache`), double/single-quoted values, and unquoted values:
|
|
18
|
-
* `cache options="a b" ttl='1h'` → `{ cache: true, options: 'a b', ttl: '1h' }`.
|
|
19
|
-
*
|
|
20
|
-
* @param {string} [raw] - Raw attribute text between the role and the closing `>`
|
|
21
|
-
* @returns {Record<string, string | true>} Parsed attributes
|
|
22
|
-
*/
|
|
23
|
-
export const parseAttributes = ( raw = '' ) =>
|
|
24
|
-
Object.fromEntries(
|
|
25
|
-
[ ...raw.matchAll( ATTRIBUTE_PATTERN ) ].map(
|
|
26
|
-
( [ _, key, doubleQuoted, singleQuoted, bare ] ) =>
|
|
27
|
-
[ key, doubleQuoted ?? singleQuoted ?? bare ?? true ]
|
|
28
|
-
)
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Tokenize a rendered prompt body into message blocks. Each block is `{ role, content }`,
|
|
33
|
-
* plus `attributes` when the opening tag carried any. Content between role tags is treated
|
|
34
|
-
* as opaque text, so prompt bodies may freely contain other angle-bracket markup.
|
|
35
|
-
*
|
|
36
|
-
* @param {string} content - Rendered prompt body (after frontmatter is stripped)
|
|
37
|
-
* @returns {Array<{ role: string, content: string, attributes?: Record<string, string | true> }>}
|
|
38
|
-
*/
|
|
39
|
-
export const tokenizeBlocks = content =>
|
|
40
|
-
[ ...content.matchAll( BLOCK_PATTERN ) ].map( ( [ _, role, rawAttributes, text ] ) => {
|
|
41
|
-
const attributes = parseAttributes( rawAttributes.trim() );
|
|
42
|
-
return {
|
|
43
|
-
role,
|
|
44
|
-
content: text.trim(),
|
|
45
|
-
...( Object.keys( attributes ).length > 0 && { attributes } )
|
|
46
|
-
};
|
|
47
|
-
} );
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseAttributes, tokenizeBlocks, BLOCK_ROLES } from './blocks.js';
|
|
3
|
-
|
|
4
|
-
describe( 'parseAttributes', () => {
|
|
5
|
-
it( 'parses a bare attribute as boolean true', () => {
|
|
6
|
-
expect( parseAttributes( 'pinned' ) ).toEqual( { pinned: true } );
|
|
7
|
-
} );
|
|
8
|
-
|
|
9
|
-
it( 'parses double- and single-quoted values', () => {
|
|
10
|
-
expect( parseAttributes( 'ttl="1h" mode=\'fast\'' ) ).toEqual( { ttl: '1h', mode: 'fast' } );
|
|
11
|
-
} );
|
|
12
|
-
|
|
13
|
-
it( 'parses unquoted values', () => {
|
|
14
|
-
expect( parseAttributes( 'ttl=1h' ) ).toEqual( { ttl: '1h' } );
|
|
15
|
-
} );
|
|
16
|
-
|
|
17
|
-
it( 'parses multiple attributes and preserves spaces inside quotes', () => {
|
|
18
|
-
expect( parseAttributes( 'pinned options="cached fast"' ) ).toEqual( {
|
|
19
|
-
pinned: true,
|
|
20
|
-
options: 'cached fast'
|
|
21
|
-
} );
|
|
22
|
-
} );
|
|
23
|
-
|
|
24
|
-
it( 'returns an empty object for blank input', () => {
|
|
25
|
-
expect( parseAttributes( '' ) ).toEqual( {} );
|
|
26
|
-
expect( parseAttributes() ).toEqual( {} );
|
|
27
|
-
} );
|
|
28
|
-
} );
|
|
29
|
-
|
|
30
|
-
describe( 'tokenizeBlocks', () => {
|
|
31
|
-
it( 'tokenizes plain blocks without an attributes key', () => {
|
|
32
|
-
const blocks = tokenizeBlocks( '<system>Hi</system>\n<user>Yo</user>' );
|
|
33
|
-
expect( blocks ).toEqual( [
|
|
34
|
-
{ role: 'system', content: 'Hi' },
|
|
35
|
-
{ role: 'user', content: 'Yo' }
|
|
36
|
-
] );
|
|
37
|
-
} );
|
|
38
|
-
|
|
39
|
-
it( 'attaches parsed attributes to the block', () => {
|
|
40
|
-
const blocks = tokenizeBlocks( '<system options="a b" pinned>Hi</system>' );
|
|
41
|
-
expect( blocks[0] ).toEqual( {
|
|
42
|
-
role: 'system',
|
|
43
|
-
content: 'Hi',
|
|
44
|
-
attributes: { options: 'a b', pinned: true }
|
|
45
|
-
} );
|
|
46
|
-
} );
|
|
47
|
-
|
|
48
|
-
it( 'captures unknown attributes generically (validation rejects them later)', () => {
|
|
49
|
-
const blocks = tokenizeBlocks( '<user data="x">Hi</user>' );
|
|
50
|
-
expect( blocks[0].attributes ).toEqual( { data: 'x' } );
|
|
51
|
-
} );
|
|
52
|
-
|
|
53
|
-
it( 'treats angle-bracket markup inside a block as opaque content', () => {
|
|
54
|
-
const blocks = tokenizeBlocks( '<user>Compare <div> and <span> tags</user>' );
|
|
55
|
-
expect( blocks[0] ).toEqual( { role: 'user', content: 'Compare <div> and <span> tags' } );
|
|
56
|
-
} );
|
|
57
|
-
|
|
58
|
-
it( 'tokenizes every registered role', () => {
|
|
59
|
-
const body = [ ...BLOCK_ROLES ].map( role => `<${role}>${role} body</${role}>` ).join( '\n' );
|
|
60
|
-
const blocks = tokenizeBlocks( body );
|
|
61
|
-
expect( blocks.map( block => block.role ) ).toEqual( [ ...BLOCK_ROLES ] );
|
|
62
|
-
} );
|
|
63
|
-
} );
|
|
@@ -1,159 +0,0 @@
|
|
|
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
|
-
} );
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { join } from 'path';
|
|
3
|
-
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
|
|
6
|
-
// Hoisted state so mocks can read dynamic values set in tests
|
|
7
|
-
const state = vi.hoisted( () => ( { dir: '', entries: {} } ) );
|
|
8
|
-
|
|
9
|
-
// Mock SDK helpers to control resolveInvocationDir
|
|
10
|
-
vi.mock( '@outputai/core/sdk/helpers', () => ( {
|
|
11
|
-
Path: {
|
|
12
|
-
resolveInvocationDir: () => state.dir
|
|
13
|
-
}
|
|
14
|
-
} ) );
|
|
15
|
-
|
|
16
|
-
// Mock node:fs.readFileSync for directory scans while delegating file reads
|
|
17
|
-
vi.mock( 'node:fs', async importOriginal => {
|
|
18
|
-
const actual = await importOriginal();
|
|
19
|
-
return {
|
|
20
|
-
...actual,
|
|
21
|
-
readFileSync: vi.fn( ( path, options ) => {
|
|
22
|
-
if ( options && typeof options === 'object' && options.withFileTypes ) {
|
|
23
|
-
return state.entries[path] || [];
|
|
24
|
-
}
|
|
25
|
-
return actual.readFileSync( path, options );
|
|
26
|
-
} )
|
|
27
|
-
};
|
|
28
|
-
} );
|
|
29
|
-
|
|
30
|
-
const dirEntry = ( name, { isDir = false, isLink = false } = {} ) => ( {
|
|
31
|
-
name,
|
|
32
|
-
isDirectory: () => isDir,
|
|
33
|
-
isSymbolicLink: () => isLink
|
|
34
|
-
} );
|
|
35
|
-
|
|
36
|
-
beforeEach( () => {
|
|
37
|
-
state.dir = '';
|
|
38
|
-
state.entries = {};
|
|
39
|
-
} );
|
|
40
|
-
|
|
41
|
-
describe( 'loadContent', () => {
|
|
42
|
-
it( 'loads file from root directory using mocked resolveInvocationDir', async () => {
|
|
43
|
-
const tempDir = mkdtempSync( join( tmpdir(), 'load-content-test-' ) );
|
|
44
|
-
state.dir = tempDir;
|
|
45
|
-
const testContent = 'test file content';
|
|
46
|
-
writeFileSync( join( tempDir, 'test.txt' ), testContent );
|
|
47
|
-
state.entries[tempDir] = [ dirEntry( 'test.txt' ) ];
|
|
48
|
-
|
|
49
|
-
const { loadContent } = await import( './load_content.js' );
|
|
50
|
-
const result = loadContent( 'test.txt' );
|
|
51
|
-
|
|
52
|
-
expect( result ).toEqual( { content: testContent, dir: tempDir } );
|
|
53
|
-
} );
|
|
54
|
-
|
|
55
|
-
it( 'loads file from nested subdirectory via recursion', async () => {
|
|
56
|
-
const tempDir = mkdtempSync( join( tmpdir(), 'load-content-test-' ) );
|
|
57
|
-
const subDir = join( tempDir, 'subdir' );
|
|
58
|
-
mkdirSync( subDir );
|
|
59
|
-
state.dir = tempDir;
|
|
60
|
-
|
|
61
|
-
const testContent = 'nested file content';
|
|
62
|
-
writeFileSync( join( subDir, 'nested.txt' ), testContent );
|
|
63
|
-
|
|
64
|
-
state.entries[tempDir] = [ dirEntry( 'subdir', { isDir: true } ) ];
|
|
65
|
-
state.entries[subDir] = [ dirEntry( 'nested.txt' ) ];
|
|
66
|
-
|
|
67
|
-
const { loadContent } = await import( './load_content.js' );
|
|
68
|
-
const result = loadContent( 'nested.txt' );
|
|
69
|
-
|
|
70
|
-
expect( result ).toEqual( { content: testContent, dir: subDir } );
|
|
71
|
-
} );
|
|
72
|
-
|
|
73
|
-
it( 'returns null when file does not exist', async () => {
|
|
74
|
-
const tempDir = mkdtempSync( join( tmpdir(), 'load-content-test-' ) );
|
|
75
|
-
state.dir = tempDir;
|
|
76
|
-
state.entries[tempDir] = [ dirEntry( 'other.txt' ) ];
|
|
77
|
-
|
|
78
|
-
const { loadContent } = await import( './load_content.js' );
|
|
79
|
-
const result = loadContent( 'nonexistent.txt' );
|
|
80
|
-
|
|
81
|
-
expect( result ).toBeNull();
|
|
82
|
-
} );
|
|
83
|
-
} );
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { loadPrompt } from './loader.js';
|
|
3
|
-
|
|
4
|
-
vi.mock( './load_content.js', () => ( {
|
|
5
|
-
loadContent: vi.fn()
|
|
6
|
-
} ) );
|
|
7
|
-
|
|
8
|
-
import { loadContent } from './load_content.js';
|
|
9
|
-
|
|
10
|
-
describe( 'loadPrompt - validation with real schema', () => {
|
|
11
|
-
beforeEach( () => {
|
|
12
|
-
vi.clearAllMocks();
|
|
13
|
-
} );
|
|
14
|
-
|
|
15
|
-
it( 'should accept valid camelCase format with providerOptions and budgetTokens', () => {
|
|
16
|
-
const promptContent = `---
|
|
17
|
-
provider: anthropic
|
|
18
|
-
model: claude-sonnet-4-20250514
|
|
19
|
-
temperature: 0.7
|
|
20
|
-
maxTokens: 64000
|
|
21
|
-
providerOptions:
|
|
22
|
-
thinking:
|
|
23
|
-
type: enabled
|
|
24
|
-
budgetTokens: 1500
|
|
25
|
-
---
|
|
26
|
-
|
|
27
|
-
<user>Hello</user>`;
|
|
28
|
-
|
|
29
|
-
loadContent.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
|
|
30
|
-
|
|
31
|
-
const result = loadPrompt( 'test', {} );
|
|
32
|
-
|
|
33
|
-
expect( result.config.providerOptions ).toBeDefined();
|
|
34
|
-
expect( result.config.providerOptions.thinking.type ).toBe( 'enabled' );
|
|
35
|
-
expect( result.config.providerOptions.thinking.budgetTokens ).toBe( 1500 );
|
|
36
|
-
} );
|
|
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
|
-
|
|
71
|
-
it( 'should accept snake_case max_tokens via config passthrough (no longer strict)', () => {
|
|
72
|
-
const promptContent = `---
|
|
73
|
-
provider: anthropic
|
|
74
|
-
model: claude-sonnet-4-20250514
|
|
75
|
-
max_tokens: 64000
|
|
76
|
-
---
|
|
77
|
-
|
|
78
|
-
<user>Hello</user>`;
|
|
79
|
-
|
|
80
|
-
loadContent.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
|
|
81
|
-
|
|
82
|
-
// Config uses passthrough, so max_tokens is accepted (though ignored by SDK)
|
|
83
|
-
expect( () => {
|
|
84
|
-
loadPrompt( 'test', {} );
|
|
85
|
-
} ).not.toThrow();
|
|
86
|
-
} );
|
|
87
|
-
|
|
88
|
-
it( 'should accept "options" field via config passthrough (no longer strict)', () => {
|
|
89
|
-
const promptContent = `---
|
|
90
|
-
provider: anthropic
|
|
91
|
-
model: claude-sonnet-4-20250514
|
|
92
|
-
options:
|
|
93
|
-
thinking:
|
|
94
|
-
type: enabled
|
|
95
|
-
budgetTokens: 1500
|
|
96
|
-
---
|
|
97
|
-
|
|
98
|
-
<user>Hello</user>`;
|
|
99
|
-
|
|
100
|
-
loadContent.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
|
|
101
|
-
|
|
102
|
-
// Config uses passthrough, so 'options' passes through (though not used)
|
|
103
|
-
expect( () => {
|
|
104
|
-
loadPrompt( 'test', {} );
|
|
105
|
-
} ).not.toThrow();
|
|
106
|
-
} );
|
|
107
|
-
|
|
108
|
-
it( 'should accept snake_case budget_tokens in thinking via passthrough', () => {
|
|
109
|
-
const promptContent = `---
|
|
110
|
-
provider: anthropic
|
|
111
|
-
model: claude-sonnet-4-20250514
|
|
112
|
-
providerOptions:
|
|
113
|
-
thinking:
|
|
114
|
-
type: enabled
|
|
115
|
-
budget_tokens: 1500
|
|
116
|
-
---
|
|
117
|
-
|
|
118
|
-
<user>Hello</user>`;
|
|
119
|
-
|
|
120
|
-
loadContent.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
|
|
121
|
-
|
|
122
|
-
// budget_tokens is silently stripped from thinking (unknown field), not rejected
|
|
123
|
-
expect( () => {
|
|
124
|
-
loadPrompt( 'test', {} );
|
|
125
|
-
} ).not.toThrow();
|
|
126
|
-
} );
|
|
127
|
-
|
|
128
|
-
} );
|
package/src/prompt/parser.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import matter from 'gray-matter';
|
|
2
|
-
import { FatalError } from '@outputai/core';
|
|
3
|
-
import { tokenizeBlocks } from './blocks.js';
|
|
4
|
-
|
|
5
|
-
export function parsePrompt( { name, raw } ) {
|
|
6
|
-
const { data: config, content } = matter( raw );
|
|
7
|
-
|
|
8
|
-
if ( !content || content.trim() === '' ) {
|
|
9
|
-
throw new FatalError( `Prompt "${name}" has no content after frontmatter` );
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const messages = tokenizeBlocks( content );
|
|
13
|
-
const instructions = messages.length === 0 ? content.trim() : null;
|
|
14
|
-
|
|
15
|
-
return { config, messages, instructions };
|
|
16
|
-
}
|
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parsePrompt } from './parser.js';
|
|
3
|
-
|
|
4
|
-
describe( 'parsePrompt', () => {
|
|
5
|
-
it( 'parses frontmatter config and message blocks', () => {
|
|
6
|
-
const raw = `---
|
|
7
|
-
provider: anthropic
|
|
8
|
-
model: claude-3-5-sonnet-20241022
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
<system>You are a helpful assistant.</system>
|
|
12
|
-
<user>Hello!</user>`;
|
|
13
|
-
|
|
14
|
-
const result = parsePrompt( { name: 'test', raw } );
|
|
15
|
-
|
|
16
|
-
expect( result.config ).toEqual( {
|
|
17
|
-
provider: 'anthropic',
|
|
18
|
-
model: 'claude-3-5-sonnet-20241022'
|
|
19
|
-
} );
|
|
20
|
-
expect( result.messages ).toHaveLength( 2 );
|
|
21
|
-
expect( result.messages[0] ).toEqual( {
|
|
22
|
-
role: 'system',
|
|
23
|
-
content: 'You are a helpful assistant.'
|
|
24
|
-
} );
|
|
25
|
-
expect( result.messages[1] ).toEqual( {
|
|
26
|
-
role: 'user',
|
|
27
|
-
content: 'Hello!'
|
|
28
|
-
} );
|
|
29
|
-
expect( result.instructions ).toBeNull();
|
|
30
|
-
} );
|
|
31
|
-
|
|
32
|
-
it( 'parses raw instructions when no message blocks are present', () => {
|
|
33
|
-
const raw = `---
|
|
34
|
-
provider: openai
|
|
35
|
-
model: gpt-image-1
|
|
36
|
-
---
|
|
37
|
-
|
|
38
|
-
Generate a cinematic image of a NASCAR race at sunset.`;
|
|
39
|
-
|
|
40
|
-
const result = parsePrompt( { name: 'image_prompt', raw } );
|
|
41
|
-
|
|
42
|
-
expect( result ).toEqual( {
|
|
43
|
-
config: {
|
|
44
|
-
provider: 'openai',
|
|
45
|
-
model: 'gpt-image-1'
|
|
46
|
-
},
|
|
47
|
-
messages: [],
|
|
48
|
-
instructions: 'Generate a cinematic image of a NASCAR race at sunset.'
|
|
49
|
-
} );
|
|
50
|
-
} );
|
|
51
|
-
|
|
52
|
-
it( 'trims raw instructions', () => {
|
|
53
|
-
const raw = `---
|
|
54
|
-
provider: openai
|
|
55
|
-
model: gpt-image-1
|
|
56
|
-
---
|
|
57
|
-
|
|
58
|
-
Generate a poster.
|
|
59
|
-
|
|
60
|
-
`;
|
|
61
|
-
|
|
62
|
-
const result = parsePrompt( { name: 'image_prompt', raw } );
|
|
63
|
-
|
|
64
|
-
expect( result.instructions ).toBe( 'Generate a poster.' );
|
|
65
|
-
} );
|
|
66
|
-
|
|
67
|
-
it( 'throws error when content is empty', () => {
|
|
68
|
-
const raw = `---
|
|
69
|
-
provider: anthropic
|
|
70
|
-
model: claude-3-5-sonnet-20241022
|
|
71
|
-
---
|
|
72
|
-
|
|
73
|
-
`;
|
|
74
|
-
|
|
75
|
-
expect( () => {
|
|
76
|
-
parsePrompt( { name: 'empty_prompt', raw } );
|
|
77
|
-
} ).toThrow( /Prompt "empty_prompt" has no content after frontmatter/ );
|
|
78
|
-
} );
|
|
79
|
-
|
|
80
|
-
it( 'parses providerOptions with budgetTokens in camelCase', () => {
|
|
81
|
-
const raw = `---
|
|
82
|
-
provider: anthropic
|
|
83
|
-
model: claude-sonnet-4-20250514
|
|
84
|
-
temperature: 0.7
|
|
85
|
-
maxTokens: 64000
|
|
86
|
-
providerOptions:
|
|
87
|
-
thinking:
|
|
88
|
-
type: enabled
|
|
89
|
-
budgetTokens: 1500
|
|
90
|
-
---
|
|
91
|
-
|
|
92
|
-
<user>Test</user>`;
|
|
93
|
-
|
|
94
|
-
const result = parsePrompt( { name: 'test', raw } );
|
|
95
|
-
|
|
96
|
-
expect( result.config.provider ).toBe( 'anthropic' );
|
|
97
|
-
expect( result.config.model ).toBe( 'claude-sonnet-4-20250514' );
|
|
98
|
-
expect( result.config.temperature ).toBe( 0.7 );
|
|
99
|
-
expect( result.config.maxTokens ).toBe( 64000 );
|
|
100
|
-
expect( result.config.providerOptions ).toBeDefined();
|
|
101
|
-
expect( result.config.providerOptions.thinking ).toBeDefined();
|
|
102
|
-
expect( result.config.providerOptions.thinking.type ).toBe( 'enabled' );
|
|
103
|
-
expect( result.config.providerOptions.thinking.budgetTokens ).toBe( 1500 );
|
|
104
|
-
} );
|
|
105
|
-
|
|
106
|
-
it( 'parses snake_case fields as-is so validation can catch them later', () => {
|
|
107
|
-
const raw = `---
|
|
108
|
-
provider: anthropic
|
|
109
|
-
model: claude-sonnet-4-20250514
|
|
110
|
-
temperature: 0.7
|
|
111
|
-
max_tokens: 64000
|
|
112
|
-
providerOptions:
|
|
113
|
-
thinking:
|
|
114
|
-
type: enabled
|
|
115
|
-
budget_tokens: 1500
|
|
116
|
-
---
|
|
117
|
-
|
|
118
|
-
<user>Test</user>`;
|
|
119
|
-
|
|
120
|
-
const result = parsePrompt( { name: 'test', raw } );
|
|
121
|
-
|
|
122
|
-
expect( result.config.provider ).toBe( 'anthropic' );
|
|
123
|
-
expect( result.config.model ).toBe( 'claude-sonnet-4-20250514' );
|
|
124
|
-
expect( result.config.temperature ).toBe( 0.7 );
|
|
125
|
-
expect( result.config.max_tokens ).toBe( 64000 );
|
|
126
|
-
expect( result.config.maxTokens ).toBeUndefined();
|
|
127
|
-
expect( result.config.providerOptions ).toBeDefined();
|
|
128
|
-
expect( result.config.providerOptions.thinking ).toBeDefined();
|
|
129
|
-
expect( result.config.providerOptions.thinking.type ).toBe( 'enabled' );
|
|
130
|
-
expect( result.config.providerOptions.thinking.budget_tokens ).toBe( 1500 );
|
|
131
|
-
expect( result.config.providerOptions.thinking.budgetTokens ).toBeUndefined();
|
|
132
|
-
} );
|
|
133
|
-
|
|
134
|
-
it( 'parses supported message roles', () => {
|
|
135
|
-
const raw = `---
|
|
136
|
-
provider: anthropic
|
|
137
|
-
model: claude-3-5-sonnet-20241022
|
|
138
|
-
---
|
|
139
|
-
|
|
140
|
-
<system>System message</system>
|
|
141
|
-
<assistant>Assistant message</assistant>
|
|
142
|
-
<tool>Tool message</tool>
|
|
143
|
-
<user>User message</user>`;
|
|
144
|
-
|
|
145
|
-
const result = parsePrompt( { name: 'test', raw } );
|
|
146
|
-
|
|
147
|
-
expect( result.messages ).toEqual( [
|
|
148
|
-
{
|
|
149
|
-
role: 'system',
|
|
150
|
-
content: 'System message'
|
|
151
|
-
},
|
|
152
|
-
{
|
|
153
|
-
role: 'assistant',
|
|
154
|
-
content: 'Assistant message'
|
|
155
|
-
},
|
|
156
|
-
{
|
|
157
|
-
role: 'tool',
|
|
158
|
-
content: 'Tool message'
|
|
159
|
-
},
|
|
160
|
-
{
|
|
161
|
-
role: 'user',
|
|
162
|
-
content: 'User message'
|
|
163
|
-
}
|
|
164
|
-
] );
|
|
165
|
-
expect( result.instructions ).toBeNull();
|
|
166
|
-
} );
|
|
167
|
-
|
|
168
|
-
it( 'surfaces block opening-tag attributes as an attributes object', () => {
|
|
169
|
-
const raw = `---
|
|
170
|
-
provider: anthropic
|
|
171
|
-
model: claude-sonnet-4-5
|
|
172
|
-
---
|
|
173
|
-
|
|
174
|
-
<system options="cached">Static.</system>
|
|
175
|
-
<user>Question</user>`;
|
|
176
|
-
|
|
177
|
-
const result = parsePrompt( { name: 'test', raw } );
|
|
178
|
-
|
|
179
|
-
expect( result.messages[0] ).toEqual( {
|
|
180
|
-
role: 'system',
|
|
181
|
-
content: 'Static.',
|
|
182
|
-
attributes: { options: 'cached' }
|
|
183
|
-
} );
|
|
184
|
-
expect( result.messages[1] ).toEqual( { role: 'user', content: 'Question' } );
|
|
185
|
-
} );
|
|
186
|
-
} );
|