@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,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 &lt;', () => {
7
- expect( encodeFilter( '<' ) ).toBe( '&lt;' );
8
- } );
9
-
10
- it( 'encodes > to &gt;', () => {
11
- expect( encodeFilter( '>' ) ).toBe( '&gt;' );
12
- } );
13
-
14
- it( 'encodes & to &amp;', () => {
15
- expect( encodeFilter( '&' ) ).toBe( '&amp;' );
16
- } );
17
-
18
- it( 'encodes a string with multiple special characters in one pass', () => {
19
- expect( encodeFilter( '<a & b>' ) ).toBe( '&lt;a &amp; b&gt;' );
20
- } );
21
-
22
- it( 'encodes a tag-shaped substring so the parser cannot tokenize it', () => {
23
- expect( encodeFilter( '<system>x</system>' ) ).toBe( '&lt;system&gt;x&lt;/system&gt;' );
24
- } );
25
-
26
- it( 'returns an empty string for null', () => {
27
- expect( encodeFilter( null ) ).toBe( '' );
28
- } );
29
-
30
- it( 'returns an empty string for undefined', () => {
31
- expect( encodeFilter( undefined ) ).toBe( '' );
32
- } );
33
-
34
- it( 'coerces numbers to string before encoding', () => {
35
- expect( encodeFilter( 42 ) ).toBe( '42' );
36
- } );
37
-
38
- it( 'coerces booleans to string before encoding', () => {
39
- expect( encodeFilter( true ) ).toBe( 'true' );
40
- expect( encodeFilter( false ) ).toBe( 'false' );
41
- } );
42
-
43
- it( 'passes empty strings through unchanged', () => {
44
- expect( encodeFilter( '' ) ).toBe( '' );
45
- } );
46
-
47
- it( 'passes plain text through unchanged', () => {
48
- expect( encodeFilter( 'hello world' ) ).toBe( 'hello world' );
49
- } );
50
- } );
51
-
52
- describe( 'setupLiquidEncodeFilter', () => {
53
- it( 'registers the safety filter on a Liquid instance', async () => {
54
- const liquid = new Liquid();
55
-
56
- setupLiquidEncodeFilter( liquid );
57
-
58
- await expect( liquid.parseAndRender( '{{ value | __var_safe }}', {
59
- value: '<system>x</system>'
60
- } ) ).resolves.toBe( '&lt;system&gt;x&lt;/system&gt;' );
61
- } );
62
- } );
63
-
64
- describe( 'escape', () => {
65
- it( 'rewrites a single {{ var }} to append the safety filter', () => {
66
- expect( escape( '{{ name }}' ) ).toBe( '{{ name | __var_safe }}' );
67
- } );
68
-
69
- it( 'rewrites multiple expressions in the same string', () => {
70
- expect( escape( '{{ a }} and {{ b }}' ) ).toBe(
71
- '{{ a | __var_safe }} and {{ b | __var_safe }}'
72
- );
73
- } );
74
-
75
- it( 'appends the safety filter last in an existing filter chain', () => {
76
- expect( escape( '{{ x | upcase }}' ) ).toBe(
77
- '{{ x | upcase | __var_safe }}'
78
- );
79
- } );
80
-
81
- it( 'handles longer filter chains', () => {
82
- expect( escape( '{{ x | a | b }}' ) ).toBe(
83
- '{{ x | a | b | __var_safe }}'
84
- );
85
- } );
86
-
87
- it( 'handles dotted property paths', () => {
88
- expect( escape( '{{ obj.field }}' ) ).toBe(
89
- '{{ obj.field | __var_safe }}'
90
- );
91
- } );
92
-
93
- it( 'preserves a {% raw %} block untouched even when it contains {{ ... }}', () => {
94
- const input = '{% raw %}{{ literal }}{% endraw %}';
95
- expect( escape( input ) ).toBe( input );
96
- } );
97
-
98
- it( 'rewrites {{ ... }} outside a raw block while preserving the raw block', () => {
99
- expect( escape( '{{ a }}{% raw %}{{ b }}{% endraw %}{{ c }}' ) ).toBe(
100
- '{{ a | __var_safe }}{% raw %}{{ b }}{% endraw %}{{ c | __var_safe }}'
101
- );
102
- } );
103
-
104
- it( 'leaves {% if %} control tags untouched but still arms {{ ... }} inside them', () => {
105
- expect( escape( '{% if cond %}{{ x }}{% endif %}' ) ).toBe(
106
- '{% if cond %}{{ x | __var_safe }}{% endif %}'
107
- );
108
- } );
109
-
110
- it( 'leaves {% for %} control tags untouched but still arms {{ ... }} inside them', () => {
111
- expect( escape( '{% for x in xs %}{{ x }}{% endfor %}' ) ).toBe(
112
- '{% for x in xs %}{{ x | __var_safe }}{% endfor %}'
113
- );
114
- } );
115
-
116
- it( 'normalizes interior whitespace via expressionContent.trim()', () => {
117
- expect( escape( '{{x}}' ) ).toBe( '{{ x | __var_safe }}' );
118
- expect( escape( '{{ x }}' ) ).toBe( '{{ x | __var_safe }}' );
119
- } );
120
-
121
- it( 'returns the input unchanged when there are no {{ ... }} expressions', () => {
122
- expect( escape( '<user>plain text</user>' ) ).toBe(
123
- '<user>plain text</user>'
124
- );
125
- } );
126
-
127
- it( 'handles an empty string', () => {
128
- expect( escape( '' ) ).toBe( '' );
129
- } );
130
- } );
131
-
132
- describe( 'decode', () => {
133
- it( 'decodes XML entities in a string', () => {
134
- expect( decode( 'R&amp;D &lt; Speed &gt; &quot;Limits&quot;' ) ).toBe( 'R&D < Speed > "Limits"' );
135
- } );
136
-
137
- it( 'decodes XML entities recursively in arrays and plain objects', () => {
138
- expect( decode( {
139
- label: 'R&amp;D',
140
- values: [ 'A &lt; B' ],
141
- nested: {
142
- title: '&quot;Race&quot;'
143
- }
144
- } ) ).toEqual( {
145
- label: 'R&D',
146
- values: [ 'A < B' ],
147
- nested: {
148
- title: '"Race"'
149
- }
150
- } );
151
- } );
152
-
153
- it( 'returns non-string scalar values unchanged', () => {
154
- expect( decode( null ) ).toBeNull();
155
- expect( decode( undefined ) ).toBeUndefined();
156
- expect( decode( 42 ) ).toBe( 42 );
157
- expect( decode( true ) ).toBe( true );
158
- } );
159
- } );
@@ -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
- } );
@@ -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
- } );