@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/LICENSE +201 -0
- package/README.md +11 -0
- package/package.json +31 -0
- package/src/ai_model.js +130 -0
- package/src/ai_model.spec.js +779 -0
- package/src/ai_sdk.js +145 -0
- package/src/ai_sdk.spec.js +633 -0
- package/src/cost/fetch_models_pricing.js +38 -0
- package/src/cost/fetch_models_pricing.spec.js +121 -0
- package/src/cost/fixtures/models_api_light.json +661 -0
- package/src/cost/index.js +73 -0
- package/src/cost/index.spec.js +162 -0
- package/src/index.d.ts +309 -0
- package/src/index.js +8 -0
- package/src/load_content.js +43 -0
- package/src/load_content.spec.js +81 -0
- package/src/parser.js +28 -0
- package/src/parser.spec.js +122 -0
- package/src/prompt_loader.js +45 -0
- package/src/prompt_loader.spec.js +179 -0
- package/src/prompt_loader_validation.spec.js +95 -0
- package/src/prompt_validations.js +60 -0
- package/src/prompt_validations.spec.js +346 -0
- package/src/source_extraction.js +49 -0
- package/src/source_extraction.spec.js +169 -0
- package/src/validations.js +21 -0
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
|
+
}
|