@outputai/llm 0.1.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/src/parser.js ADDED
@@ -0,0 +1,28 @@
1
+ import matter from 'gray-matter';
2
+ import { FatalError } from '@outputai/core';
3
+
4
+ export function parsePrompt( raw ) {
5
+ const { data: config, content } = matter( raw );
6
+
7
+ if ( !content || content.trim() === '' ) {
8
+ throw new FatalError( 'Prompt file 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
+ if ( messages.length === 0 ) {
17
+ const contentPreview = content.substring( 0, 200 );
18
+ const ellipsis = content.length > 200 ? '...' : '';
19
+
20
+ throw new FatalError(
21
+ `No valid message blocks found in prompt file.
22
+ Expected format: <system>...</system>, <user>...</user>, etc.
23
+ Content preview: ${contentPreview}${ellipsis}`
24
+ );
25
+ }
26
+
27
+ return { config, messages };
28
+ }
@@ -0,0 +1,122 @@
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( 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
+ } );
30
+
31
+ it( 'throws error when content is empty', () => {
32
+ const raw = `---
33
+ provider: anthropic
34
+ model: claude-3-5-sonnet-20241022
35
+ ---
36
+
37
+ `;
38
+
39
+ expect( () => {
40
+ parsePrompt( raw );
41
+ } ).toThrow( /no content after frontmatter/ );
42
+ } );
43
+
44
+ it( 'throws error when no valid message blocks found', () => {
45
+ const raw = `---
46
+ provider: anthropic
47
+ model: claude-3-5-sonnet-20241022
48
+ ---
49
+
50
+ This is just plain text without any message tags.`;
51
+
52
+ expect( () => {
53
+ parsePrompt( raw );
54
+ } ).toThrow( /No valid message blocks found/ );
55
+ expect( () => {
56
+ parsePrompt( raw );
57
+ } ).toThrow( /Expected format/ );
58
+ } );
59
+
60
+ it( 'should use providerOptions with budgetTokens in camelCase', () => {
61
+ // Frontmatter uses canonical format: providerOptions.thinking.budgetTokens
62
+ const raw = `---
63
+ provider: anthropic
64
+ model: claude-sonnet-4-20250514
65
+ temperature: 0.7
66
+ maxTokens: 64000
67
+ providerOptions:
68
+ thinking:
69
+ type: enabled
70
+ budgetTokens: 1500
71
+ ---
72
+
73
+ <user>Test</user>`;
74
+
75
+ const result = parsePrompt( raw );
76
+
77
+ expect( result.config.provider ).toBe( 'anthropic' );
78
+ expect( result.config.model ).toBe( 'claude-sonnet-4-20250514' );
79
+ expect( result.config.temperature ).toBe( 0.7 );
80
+ expect( result.config.maxTokens ).toBe( 64000 );
81
+
82
+ // Now expects canonical schema format in front-matter
83
+ expect( result.config.providerOptions ).toBeDefined();
84
+ expect( result.config.providerOptions.thinking ).toBeDefined();
85
+ expect( result.config.providerOptions.thinking.type ).toBe( 'enabled' );
86
+ expect( result.config.providerOptions.thinking.budgetTokens ).toBe( 1500 );
87
+ } );
88
+
89
+ it( 'should parse snake_case fields as-is (validation catches them later)', () => {
90
+ // Parser extracts fields as-is from frontmatter; validation happens later
91
+ const raw = `---
92
+ provider: anthropic
93
+ model: claude-sonnet-4-20250514
94
+ temperature: 0.7
95
+ max_tokens: 64000
96
+ providerOptions:
97
+ thinking:
98
+ type: enabled
99
+ budget_tokens: 1500
100
+ ---
101
+
102
+ <user>Test</user>`;
103
+
104
+ const result = parsePrompt( raw );
105
+
106
+ // Parser extracts snake_case fields as-is
107
+ expect( result.config.provider ).toBe( 'anthropic' );
108
+ expect( result.config.model ).toBe( 'claude-sonnet-4-20250514' );
109
+ expect( result.config.temperature ).toBe( 0.7 );
110
+ // snake_case preserved here because we're only calling parsePrompt, not validatePrompt
111
+ expect( result.config.max_tokens ).toBe( 64000 );
112
+ // camelCase not set because we only call parsePrompt
113
+ expect( result.config.maxTokens ).toBeUndefined();
114
+
115
+ // Snake_case fields in nested objects also preserved
116
+ expect( result.config.providerOptions ).toBeDefined();
117
+ expect( result.config.providerOptions.thinking ).toBeDefined();
118
+ expect( result.config.providerOptions.thinking.type ).toBe( 'enabled' );
119
+ expect( result.config.providerOptions.thinking.budget_tokens ).toBe( 1500 ); // snake_case preserved
120
+ expect( result.config.providerOptions.thinking.budgetTokens ).toBeUndefined(); // camelCase not set
121
+ } );
122
+ } );
@@ -0,0 +1,45 @@
1
+ import { parsePrompt } from './parser.js';
2
+ import { Liquid } from 'liquidjs';
3
+ import { loadContent } from './load_content.js';
4
+ import { validatePrompt } from './prompt_validations.js';
5
+ import { FatalError } from '@outputai/core';
6
+
7
+ const liquid = new Liquid();
8
+
9
+ const renderPrompt = ( name, content, values ) => {
10
+ try {
11
+ return liquid.parseAndRenderSync( content, values );
12
+ } catch ( error ) {
13
+ throw new FatalError( `Failed to render template in prompt "${name}": ${error.message}`, { cause: error } );
14
+ }
15
+ };
16
+
17
+ /**
18
+ * Load a prompt file and render it with variables.
19
+ *
20
+ * @param {string} name - Name of the prompt file (without .prompt extension)
21
+ * @param {Record<string, string | number | boolean>} [values] - Variables to interpolate
22
+ * @param {string} [dir] - Directory to search for the prompt file (defaults to stack-resolved invocation dir)
23
+ * @returns {Prompt} Loaded and rendered prompt object
24
+ */
25
+ export const loadPrompt = ( name, values = {}, dir ) => {
26
+ const promptContent = dir ? loadContent( `${name}.prompt`, dir ) : loadContent( `${name}.prompt` );
27
+ if ( !promptContent ) {
28
+ throw new FatalError( `Prompt ${name} not found.` );
29
+ }
30
+
31
+ const renderedContent = renderPrompt( name, promptContent, values );
32
+
33
+ const { config, messages } = parsePrompt( renderedContent );
34
+
35
+ const prompt = {
36
+ name,
37
+ config,
38
+ messages
39
+ };
40
+
41
+ validatePrompt( prompt );
42
+
43
+ return prompt;
44
+ };
45
+
@@ -0,0 +1,179 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { loadPrompt } from './prompt_loader.js';
3
+
4
+ // Mock dependencies that perform I/O or validation
5
+ vi.mock( './load_content.js', () => ( {
6
+ loadContent: vi.fn()
7
+ } ) );
8
+
9
+ vi.mock( './prompt_validations.js', () => ( {
10
+ validatePrompt: vi.fn()
11
+ } ) );
12
+
13
+ import { loadContent } from './load_content.js';
14
+ import { validatePrompt } from './prompt_validations.js';
15
+
16
+ describe( 'loadPrompt', () => {
17
+ beforeEach( () => {
18
+ vi.clearAllMocks();
19
+ } );
20
+
21
+ it( 'loads prompt file and renders with variables', () => {
22
+ const promptContent = `---
23
+ provider: anthropic
24
+ model: claude-3-5-sonnet-20241022
25
+ ---
26
+ <user>Hello {{ name }}!</user>`;
27
+
28
+ loadContent.mockReturnValue( promptContent );
29
+
30
+ const result = loadPrompt( 'test', { name: 'World' } );
31
+
32
+ expect( result.name ).toBe( 'test' );
33
+ expect( result.config ).toEqual( { provider: 'anthropic', model: 'claude-3-5-sonnet-20241022' } );
34
+ expect( result.messages ).toHaveLength( 1 );
35
+ expect( result.messages[0].content ).toBe( 'Hello World!' );
36
+ expect( validatePrompt ).toHaveBeenCalledWith( result );
37
+ } );
38
+
39
+ it( 'throws error when prompt file not found', () => {
40
+ loadContent.mockReturnValue( null );
41
+
42
+ expect( () => {
43
+ loadPrompt( 'nonexistent' );
44
+ } ).toThrow( /Prompt nonexistent not found/ );
45
+ } );
46
+
47
+ } );
48
+
49
+ describe( 'loadPrompt - template hydration in headers (integration tests)', () => {
50
+ beforeEach( () => {
51
+ vi.clearAllMocks();
52
+ } );
53
+
54
+ it( 'should hydrate template variables in YAML headers', () => {
55
+ const promptContent = `---
56
+ provider: {{ provider_name }}
57
+ model: {{ model_id }}
58
+ temperature: 0.7
59
+ ---
60
+
61
+ <user>Hello</user>`;
62
+
63
+ loadContent.mockReturnValue( promptContent );
64
+
65
+ const result = loadPrompt( 'test', {
66
+ provider_name: 'anthropic',
67
+ model_id: 'claude-sonnet-4'
68
+ } );
69
+
70
+ expect( result.config.provider ).toBe( 'anthropic' );
71
+ expect( result.config.model ).toBe( 'claude-sonnet-4' );
72
+ expect( result.config.temperature ).toBe( 0.7 );
73
+ } );
74
+
75
+ it( 'should hydrate template variables in both headers and messages', () => {
76
+ const promptContent = `---
77
+ provider: {{ provider }}
78
+ model: {{ model }}
79
+ ---
80
+
81
+ <user>Tell me about {{ topic }}</user>`;
82
+
83
+ loadContent.mockReturnValue( promptContent );
84
+
85
+ const result = loadPrompt( 'test', {
86
+ provider: 'openai',
87
+ model: 'gpt-4',
88
+ topic: 'testing'
89
+ } );
90
+
91
+ expect( result.config.provider ).toBe( 'openai' );
92
+ expect( result.config.model ).toBe( 'gpt-4' );
93
+ expect( result.messages[0].content ).toBe( 'Tell me about testing' );
94
+ } );
95
+
96
+ it( 'should render undefined template variables as null', () => {
97
+ const promptContent = `---
98
+ provider: {{ undefined_var }}
99
+ model: claude-3-5-sonnet-20241022
100
+ ---
101
+
102
+ <user>Hello</user>`;
103
+
104
+ loadContent.mockReturnValue( promptContent );
105
+
106
+ const result = loadPrompt( 'test', {} );
107
+
108
+ // Liquid renders undefined variables as empty, which becomes null in YAML
109
+ expect( result.config.provider ).toBe( null );
110
+ expect( result.config.model ).toBe( 'claude-3-5-sonnet-20241022' );
111
+ } );
112
+
113
+ it( 'should handle complex template expressions in headers', () => {
114
+ const promptContent = `---
115
+ provider: anthropic
116
+ model: {{ base_model }}-{{ version }}
117
+ temperature: 0.7
118
+ ---
119
+
120
+ <user>Hello</user>`;
121
+
122
+ loadContent.mockReturnValue( promptContent );
123
+
124
+ const result = loadPrompt( 'test', {
125
+ base_model: 'claude-sonnet',
126
+ version: '4'
127
+ } );
128
+
129
+ expect( result.config.model ).toBe( 'claude-sonnet-4' );
130
+ } );
131
+
132
+ it( 'should use camelCase config keys', () => {
133
+ const promptContent = `---
134
+ provider: anthropic
135
+ model: claude-3-5-sonnet-20241022
136
+ maxTokens: 1024
137
+ temperature: 0.7
138
+ ---
139
+
140
+ <user>Hello</user>`;
141
+
142
+ loadContent.mockReturnValue( promptContent );
143
+
144
+ const result = loadPrompt( 'test', {} );
145
+
146
+ expect( result.config.maxTokens ).toBe( 1024 );
147
+ } );
148
+
149
+ it( 'should render boolean variables correctly', () => {
150
+ const promptContent = `---
151
+ provider: anthropic
152
+ model: claude-3-5-sonnet-20241022
153
+ ---
154
+
155
+ <user>{% if debug %}Debug mode enabled{% else %}Debug mode disabled{% endif %}</user>`;
156
+
157
+ loadContent.mockReturnValue( promptContent );
158
+
159
+ const result = loadPrompt( 'test', { debug: true } );
160
+
161
+ expect( result.messages[0].content ).toBe( 'Debug mode enabled' );
162
+ } );
163
+
164
+ it( 'should render false boolean variables', () => {
165
+ const promptContent = `---
166
+ provider: anthropic
167
+ model: claude-3-5-sonnet-20241022
168
+ ---
169
+
170
+ <user>{% if enabled %}Feature enabled{% else %}Feature disabled{% endif %}</user>`;
171
+
172
+ loadContent.mockReturnValue( promptContent );
173
+
174
+ const result = loadPrompt( 'test', { enabled: false } );
175
+
176
+ expect( result.messages[0].content ).toBe( 'Feature disabled' );
177
+ } );
178
+
179
+ } );
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { loadPrompt } from './prompt_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( promptContent );
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 snake_case max_tokens via config passthrough (no longer strict)', () => {
39
+ const promptContent = `---
40
+ provider: anthropic
41
+ model: claude-sonnet-4-20250514
42
+ max_tokens: 64000
43
+ ---
44
+
45
+ <user>Hello</user>`;
46
+
47
+ loadContent.mockReturnValue( promptContent );
48
+
49
+ // Config uses passthrough, so max_tokens is accepted (though ignored by SDK)
50
+ expect( () => {
51
+ loadPrompt( 'test', {} );
52
+ } ).not.toThrow();
53
+ } );
54
+
55
+ it( 'should accept "options" field via config passthrough (no longer strict)', () => {
56
+ const promptContent = `---
57
+ provider: anthropic
58
+ model: claude-sonnet-4-20250514
59
+ options:
60
+ thinking:
61
+ type: enabled
62
+ budgetTokens: 1500
63
+ ---
64
+
65
+ <user>Hello</user>`;
66
+
67
+ loadContent.mockReturnValue( promptContent );
68
+
69
+ // Config uses passthrough, so 'options' passes through (though not used)
70
+ expect( () => {
71
+ loadPrompt( 'test', {} );
72
+ } ).not.toThrow();
73
+ } );
74
+
75
+ it( 'should accept snake_case budget_tokens in thinking via passthrough', () => {
76
+ const promptContent = `---
77
+ provider: anthropic
78
+ model: claude-sonnet-4-20250514
79
+ providerOptions:
80
+ thinking:
81
+ type: enabled
82
+ budget_tokens: 1500
83
+ ---
84
+
85
+ <user>Hello</user>`;
86
+
87
+ loadContent.mockReturnValue( promptContent );
88
+
89
+ // budget_tokens is silently stripped from thinking (unknown field), not rejected
90
+ expect( () => {
91
+ loadPrompt( 'test', {} );
92
+ } ).not.toThrow();
93
+ } );
94
+
95
+ } );
@@ -0,0 +1,60 @@
1
+ import { ValidationError, z } from '@outputai/core';
2
+
3
+ export const promptSchema = z.object( {
4
+ name: z.string(),
5
+ config: z.object( {
6
+ provider: z.string().min( 1 ),
7
+ model: z.string(),
8
+ temperature: z.number().optional(),
9
+ maxTokens: z.number().optional(),
10
+ tools: z.record( z.string(), z.object( {} ).passthrough() ).optional(),
11
+ providerOptions: z.object( {
12
+ thinking: z.object( {
13
+ type: z.enum( [ 'enabled', 'disabled' ] ),
14
+ budgetTokens: z.number().optional()
15
+ } ).passthrough().optional()
16
+ } ).passthrough().optional()
17
+ } ).passthrough(),
18
+ messages: z.array(
19
+ z.object( {
20
+ role: z.string(),
21
+ content: z.string()
22
+ } ).strict()
23
+ )
24
+ } ).strict();
25
+
26
+ const SNAKE_CASE_WARNINGS = {
27
+ max_tokens: 'maxTokens',
28
+ budget_tokens: 'budgetTokens',
29
+ top_p: 'topP',
30
+ top_k: 'topK',
31
+ stop_sequences: 'stopSequences',
32
+ options: 'providerOptions'
33
+ };
34
+
35
+ function warnSnakeCaseFields( config ) {
36
+ for ( const [ snake, camel ] of Object.entries( SNAKE_CASE_WARNINGS ) ) {
37
+ if ( Object.hasOwn( config, snake ) ) {
38
+ console.warn( `[output-llm] "${snake}" found in prompt config. Did you mean "${camel}"?` );
39
+ }
40
+ }
41
+ const thinking = config.providerOptions?.thinking;
42
+ if ( thinking && Object.hasOwn( thinking, 'budget_tokens' ) ) {
43
+ console.warn( '[output-llm] "budget_tokens" found in providerOptions.thinking. Did you mean "budgetTokens"?' );
44
+ }
45
+ }
46
+
47
+ export function validatePrompt( prompt ) {
48
+ const result = promptSchema.safeParse( prompt );
49
+ if ( !result.success ) {
50
+ const promptIdentifier = prompt?.name ? `"${prompt.name}"` : '(unnamed)';
51
+ const errorMessage = z.prettifyError( result.error );
52
+
53
+ throw new ValidationError(
54
+ `Invalid prompt file ${promptIdentifier}: ${errorMessage}`,
55
+ { cause: result.error }
56
+ );
57
+ }
58
+
59
+ warnSnakeCaseFields( result.data.config );
60
+ }