@output.ai/llm 0.2.2 → 0.2.4
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 +1 -1
- package/src/ai_sdk.js +8 -0
- package/src/index.d.ts +0 -3
- package/src/load_content.js +3 -2
- package/src/parser.js +7 -6
- package/src/parser.spec.js +64 -1
- package/src/prompt_loader.js +10 -25
- package/src/prompt_loader.spec.js +103 -10
- package/src/prompt_loader_validation.spec.js +174 -0
- package/src/prompt_validations.js +26 -6
- package/src/prompt_validations.spec.js +121 -0
package/package.json
CHANGED
package/src/ai_sdk.js
CHANGED
|
@@ -61,6 +61,8 @@ const extraAiSdkOptionsFromPrompt = prompt => {
|
|
|
61
61
|
* @param {object} args - Generation arguments
|
|
62
62
|
* @param {string} args.prompt - Prompt file name
|
|
63
63
|
* @param {Record<string, string | number>} [args.variables] - Variables to interpolate
|
|
64
|
+
* @throws {ValidationError} If the prompt config is invalid (e.g., snake_case fields)
|
|
65
|
+
* @throws {FatalError} If the prompt file is not found or template rendering fails
|
|
64
66
|
* @returns {Promise<string>} Generated text
|
|
65
67
|
*/
|
|
66
68
|
export async function generateText( { prompt, variables } ) {
|
|
@@ -83,6 +85,8 @@ export async function generateText( { prompt, variables } ) {
|
|
|
83
85
|
* @param {z.ZodObject} args.schema - Output schema
|
|
84
86
|
* @param {string} [args.schemaName] - Output schema name
|
|
85
87
|
* @param {string} [args.schemaDescription] - Output schema description
|
|
88
|
+
* @throws {ValidationError} If the prompt config is invalid (e.g., snake_case fields)
|
|
89
|
+
* @throws {FatalError} If the prompt file is not found or template rendering fails
|
|
86
90
|
* @returns {Promise<object>} Object matching the provided schema
|
|
87
91
|
*/
|
|
88
92
|
export async function generateObject( args ) {
|
|
@@ -112,6 +116,8 @@ export async function generateObject( args ) {
|
|
|
112
116
|
* @param {z.ZodType} args.schema - Output schema (array item)
|
|
113
117
|
* @param {string} [args.schemaName] - Output schema name
|
|
114
118
|
* @param {string} [args.schemaDescription] - Output schema description
|
|
119
|
+
* @throws {ValidationError} If the prompt config is invalid (e.g., snake_case fields)
|
|
120
|
+
* @throws {FatalError} If the prompt file is not found or template rendering fails
|
|
115
121
|
* @returns {Promise<object>} Array where each element matches the schema
|
|
116
122
|
*/
|
|
117
123
|
export async function generateArray( args ) {
|
|
@@ -139,6 +145,8 @@ export async function generateArray( args ) {
|
|
|
139
145
|
* @param {string} args.prompt - Prompt file name
|
|
140
146
|
* @param {Record<string, string | number>} [args.variables] - Variables to interpolate
|
|
141
147
|
* @param {string[]} args.enum - Allowed values for the generation
|
|
148
|
+
* @throws {ValidationError} If the prompt config is invalid (e.g., snake_case fields)
|
|
149
|
+
* @throws {FatalError} If the prompt file is not found or template rendering fails
|
|
142
150
|
* @returns {Promise<string>} One of the provided enum values
|
|
143
151
|
*/
|
|
144
152
|
export async function generateEnum( args ) {
|
package/src/index.d.ts
CHANGED
|
@@ -47,9 +47,6 @@ export type Prompt = {
|
|
|
47
47
|
/** Maximum tokens in the response */
|
|
48
48
|
maxTokens?: number;
|
|
49
49
|
|
|
50
|
-
/** Additional provider-specific options */
|
|
51
|
-
options?: Record<string, Record<string, JSONValue>>;
|
|
52
|
-
|
|
53
50
|
/** Provider-specific configurations */
|
|
54
51
|
providerOptions?: Record<string, unknown>;
|
|
55
52
|
};
|
package/src/load_content.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { join } from 'path';
|
|
2
2
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { resolveInvocationDir } from '@output.ai/core/utils';
|
|
4
|
+
import { FatalError } from '@output.ai/core';
|
|
4
5
|
|
|
5
6
|
const scanDir = dir => {
|
|
6
7
|
try {
|
|
7
8
|
return readdirSync( dir, { withFileTypes: true } );
|
|
8
9
|
} catch ( error ) {
|
|
9
|
-
throw new
|
|
10
|
+
throw new FatalError( `Error scanning directory "${dir}"`, { cause: error } );
|
|
10
11
|
}
|
|
11
12
|
};
|
|
12
13
|
|
|
@@ -14,7 +15,7 @@ const loadFile = path => {
|
|
|
14
15
|
try {
|
|
15
16
|
return readFileSync( path, 'utf-8' );
|
|
16
17
|
} catch ( error ) {
|
|
17
|
-
throw new
|
|
18
|
+
throw new FatalError( `Error reading file "${path}"`, { cause: error } );
|
|
18
19
|
}
|
|
19
20
|
};
|
|
20
21
|
|
package/src/parser.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import matter from 'gray-matter';
|
|
2
|
+
import { FatalError } from '@output.ai/core';
|
|
2
3
|
|
|
3
4
|
export function parsePrompt( raw ) {
|
|
4
|
-
const { data, content } = matter( raw );
|
|
5
|
+
const { data: config, content } = matter( raw );
|
|
5
6
|
|
|
6
7
|
if ( !content || content.trim() === '' ) {
|
|
7
|
-
throw new
|
|
8
|
+
throw new FatalError( 'Prompt file has no content after frontmatter' );
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
const infoExtractor = /<(system|user|assistant|tool)>([\s\S]*?)<\/\1>/gm;
|
|
@@ -16,12 +17,12 @@ export function parsePrompt( raw ) {
|
|
|
16
17
|
const contentPreview = content.substring( 0, 200 );
|
|
17
18
|
const ellipsis = content.length > 200 ? '...' : '';
|
|
18
19
|
|
|
19
|
-
throw new
|
|
20
|
-
`No valid message blocks found in prompt file.
|
|
21
|
-
Expected format: <system>...</system>, <user>...</user>, etc.
|
|
20
|
+
throw new FatalError(
|
|
21
|
+
`No valid message blocks found in prompt file.
|
|
22
|
+
Expected format: <system>...</system>, <user>...</user>, etc.
|
|
22
23
|
Content preview: ${contentPreview}${ellipsis}`
|
|
23
24
|
);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
return {
|
|
27
|
+
return { config, messages };
|
|
27
28
|
}
|
package/src/parser.spec.js
CHANGED
|
@@ -13,7 +13,7 @@ model: claude-3-5-sonnet-20241022
|
|
|
13
13
|
|
|
14
14
|
const result = parsePrompt( raw );
|
|
15
15
|
|
|
16
|
-
expect( result.
|
|
16
|
+
expect( result.config ).toEqual( {
|
|
17
17
|
provider: 'anthropic',
|
|
18
18
|
model: 'claude-3-5-sonnet-20241022'
|
|
19
19
|
} );
|
|
@@ -56,4 +56,67 @@ This is just plain text without any message tags.`;
|
|
|
56
56
|
parsePrompt( raw );
|
|
57
57
|
} ).toThrow( /Expected format/ );
|
|
58
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
|
+
} );
|
|
59
122
|
} );
|
package/src/prompt_loader.js
CHANGED
|
@@ -2,30 +2,15 @@ import { parsePrompt } from './parser.js';
|
|
|
2
2
|
import { Liquid } from 'liquidjs';
|
|
3
3
|
import { loadContent } from './load_content.js';
|
|
4
4
|
import { validatePrompt } from './prompt_validations.js';
|
|
5
|
+
import { FatalError } from '@output.ai/core';
|
|
5
6
|
|
|
6
7
|
const liquid = new Liquid();
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
* Render a single message with template variables.
|
|
10
|
-
*
|
|
11
|
-
* @param {string} role - The message role
|
|
12
|
-
* @param {string} content - The message content template
|
|
13
|
-
* @param {Record<string, string | number>} values - Variables to interpolate
|
|
14
|
-
* @param {string} promptName - Name of the prompt (for error messages)
|
|
15
|
-
* @param {number} index - Message index (for error messages)
|
|
16
|
-
* @returns {{role: string, content: string}} Rendered message object
|
|
17
|
-
*/
|
|
18
|
-
const renderMessage = ( role, content, values, promptName, index ) => {
|
|
9
|
+
const renderPrompt = ( name, content, values ) => {
|
|
19
10
|
try {
|
|
20
|
-
return
|
|
21
|
-
role,
|
|
22
|
-
content: liquid.parseAndRenderSync( content, values )
|
|
23
|
-
};
|
|
11
|
+
return liquid.parseAndRenderSync( content, values );
|
|
24
12
|
} catch ( error ) {
|
|
25
|
-
throw new
|
|
26
|
-
`Failed to render template in message ${index + 1} (role: ${role}) of prompt "${promptName}": ${error.message}`,
|
|
27
|
-
{ cause: error }
|
|
28
|
-
);
|
|
13
|
+
throw new FatalError( `Failed to render template in prompt "${name}": ${error.message}`, { cause: error } );
|
|
29
14
|
}
|
|
30
15
|
};
|
|
31
16
|
|
|
@@ -36,20 +21,20 @@ const renderMessage = ( role, content, values, promptName, index ) => {
|
|
|
36
21
|
* @param {Record<string, string | number>} [values] - Variables to interpolate
|
|
37
22
|
* @returns {Prompt} Loaded and rendered prompt object
|
|
38
23
|
*/
|
|
39
|
-
export const loadPrompt = ( name, values ) => {
|
|
24
|
+
export const loadPrompt = ( name, values = {} ) => {
|
|
40
25
|
const promptContent = loadContent( `${name}.prompt` );
|
|
41
26
|
if ( !promptContent ) {
|
|
42
|
-
throw new
|
|
27
|
+
throw new FatalError( `Prompt ${name} not found.` );
|
|
43
28
|
}
|
|
44
29
|
|
|
45
|
-
const
|
|
30
|
+
const renderedContent = renderPrompt( name, promptContent, values );
|
|
31
|
+
|
|
32
|
+
const { config, messages } = parsePrompt( renderedContent );
|
|
46
33
|
|
|
47
34
|
const prompt = {
|
|
48
35
|
name,
|
|
49
36
|
config,
|
|
50
|
-
messages
|
|
51
|
-
renderMessage( role, content, values, name, index )
|
|
52
|
-
)
|
|
37
|
+
messages
|
|
53
38
|
};
|
|
54
39
|
|
|
55
40
|
validatePrompt( prompt );
|
|
@@ -1,21 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { loadPrompt } from './prompt_loader.js';
|
|
3
3
|
|
|
4
|
-
// Mock dependencies
|
|
4
|
+
// Mock dependencies that perform I/O or validation
|
|
5
5
|
vi.mock( './load_content.js', () => ( {
|
|
6
6
|
loadContent: vi.fn()
|
|
7
7
|
} ) );
|
|
8
8
|
|
|
9
|
-
vi.mock( './parser.js', () => ( {
|
|
10
|
-
parsePrompt: vi.fn()
|
|
11
|
-
} ) );
|
|
12
|
-
|
|
13
9
|
vi.mock( './prompt_validations.js', () => ( {
|
|
14
10
|
validatePrompt: vi.fn()
|
|
15
11
|
} ) );
|
|
16
12
|
|
|
17
13
|
import { loadContent } from './load_content.js';
|
|
18
|
-
import { parsePrompt } from './parser.js';
|
|
19
14
|
import { validatePrompt } from './prompt_validations.js';
|
|
20
15
|
|
|
21
16
|
describe( 'loadPrompt', () => {
|
|
@@ -31,10 +26,6 @@ model: claude-3-5-sonnet-20241022
|
|
|
31
26
|
<user>Hello {{ name }}!</user>`;
|
|
32
27
|
|
|
33
28
|
loadContent.mockReturnValue( promptContent );
|
|
34
|
-
parsePrompt.mockReturnValue( {
|
|
35
|
-
data: { provider: 'anthropic', model: 'claude-3-5-sonnet-20241022' },
|
|
36
|
-
messages: [ { role: 'user', content: 'Hello {{ name }}!' } ]
|
|
37
|
-
} );
|
|
38
29
|
|
|
39
30
|
const result = loadPrompt( 'test', { name: 'World' } );
|
|
40
31
|
|
|
@@ -54,3 +45,105 @@ model: claude-3-5-sonnet-20241022
|
|
|
54
45
|
} );
|
|
55
46
|
|
|
56
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
|
+
} );
|
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
import { ValidationError } from '@output.ai/core';
|
|
10
|
+
|
|
11
|
+
describe( 'loadPrompt - validation with real schema', () => {
|
|
12
|
+
beforeEach( () => {
|
|
13
|
+
vi.clearAllMocks();
|
|
14
|
+
} );
|
|
15
|
+
|
|
16
|
+
it( 'should accept valid camelCase format with providerOptions and budgetTokens', () => {
|
|
17
|
+
// Frontmatter uses canonical format: providerOptions.thinking.budgetTokens
|
|
18
|
+
const promptContent = `---
|
|
19
|
+
provider: anthropic
|
|
20
|
+
model: claude-sonnet-4-20250514
|
|
21
|
+
temperature: 0.7
|
|
22
|
+
maxTokens: 64000
|
|
23
|
+
providerOptions:
|
|
24
|
+
thinking:
|
|
25
|
+
type: enabled
|
|
26
|
+
budgetTokens: 1500
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
<user>Hello</user>`;
|
|
30
|
+
|
|
31
|
+
loadContent.mockReturnValue( promptContent );
|
|
32
|
+
|
|
33
|
+
const result = loadPrompt( 'test', {} );
|
|
34
|
+
|
|
35
|
+
// Validation passes with canonical schema format
|
|
36
|
+
expect( result.config.providerOptions ).toBeDefined();
|
|
37
|
+
expect( result.config.providerOptions.thinking.type ).toBe( 'enabled' );
|
|
38
|
+
expect( result.config.providerOptions.thinking.budgetTokens ).toBe( 1500 );
|
|
39
|
+
} );
|
|
40
|
+
|
|
41
|
+
it( 'should REJECT snake_case max_tokens (not canonical)', () => {
|
|
42
|
+
// Using snake_case max_tokens should fail validation
|
|
43
|
+
const promptContent = `---
|
|
44
|
+
provider: anthropic
|
|
45
|
+
model: claude-sonnet-4-20250514
|
|
46
|
+
max_tokens: 64000
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
<user>Hello</user>`;
|
|
50
|
+
|
|
51
|
+
loadContent.mockReturnValue( promptContent );
|
|
52
|
+
|
|
53
|
+
// loadPrompt should throw ValidationError because schema should reject max_tokens.
|
|
54
|
+
// Other tests check the actual message, but this one specifically checks that we throw
|
|
55
|
+
// the right kind of error, just to check that as well
|
|
56
|
+
expect( () => {
|
|
57
|
+
loadPrompt( 'test', {} );
|
|
58
|
+
} ).toThrow( ValidationError );
|
|
59
|
+
expect( () => {
|
|
60
|
+
loadPrompt( 'test', {} );
|
|
61
|
+
} ).toThrow( /max_tokens/ );
|
|
62
|
+
} );
|
|
63
|
+
|
|
64
|
+
it( 'should REJECT deprecated "options" field (use providerOptions)', () => {
|
|
65
|
+
// Using 'options' instead of 'providerOptions' should fail validation
|
|
66
|
+
const promptContent = `---
|
|
67
|
+
provider: anthropic
|
|
68
|
+
model: claude-sonnet-4-20250514
|
|
69
|
+
options:
|
|
70
|
+
thinking:
|
|
71
|
+
type: enabled
|
|
72
|
+
budgetTokens: 1500
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
<user>Hello</user>`;
|
|
76
|
+
|
|
77
|
+
loadContent.mockReturnValue( promptContent );
|
|
78
|
+
|
|
79
|
+
// loadPrompt should throw ValidationError because schema should reject 'options'
|
|
80
|
+
expect( () => {
|
|
81
|
+
loadPrompt( 'test', {} );
|
|
82
|
+
} ).toThrow( /Invalid prompt file/ );
|
|
83
|
+
} );
|
|
84
|
+
|
|
85
|
+
it( 'should REJECT snake_case budget_tokens in providerOptions', () => {
|
|
86
|
+
// Using snake_case budget_tokens should fail validation
|
|
87
|
+
const promptContent = `---
|
|
88
|
+
provider: anthropic
|
|
89
|
+
model: claude-sonnet-4-20250514
|
|
90
|
+
providerOptions:
|
|
91
|
+
thinking:
|
|
92
|
+
type: enabled
|
|
93
|
+
budget_tokens: 1500
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
<user>Hello</user>`;
|
|
97
|
+
|
|
98
|
+
loadContent.mockReturnValue( promptContent );
|
|
99
|
+
|
|
100
|
+
// loadPrompt should throw ValidationError because schema rejects budget_tokens
|
|
101
|
+
expect( () => {
|
|
102
|
+
loadPrompt( 'test', {} );
|
|
103
|
+
} ).toThrow( /Invalid prompt file/ );
|
|
104
|
+
} );
|
|
105
|
+
|
|
106
|
+
it( 'end-to-end: should accept canonical camelCase format', () => {
|
|
107
|
+
// End-to-end test: frontmatter uses canonical providerOptions.thinking.budgetTokens
|
|
108
|
+
const promptContent = `---
|
|
109
|
+
provider: anthropic
|
|
110
|
+
model: claude-sonnet-4-20250514
|
|
111
|
+
temperature: 0.7
|
|
112
|
+
maxTokens: 64000
|
|
113
|
+
providerOptions:
|
|
114
|
+
thinking:
|
|
115
|
+
type: enabled
|
|
116
|
+
budgetTokens: 1500
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
<user>Hello</user>`;
|
|
120
|
+
|
|
121
|
+
loadContent.mockReturnValue( promptContent );
|
|
122
|
+
|
|
123
|
+
const result = loadPrompt( 'test', {} );
|
|
124
|
+
|
|
125
|
+
// After loadPrompt, uses canonical format
|
|
126
|
+
expect( result.config.providerOptions ).toBeDefined();
|
|
127
|
+
expect( result.config.providerOptions.thinking.type ).toBe( 'enabled' );
|
|
128
|
+
expect( result.config.providerOptions.thinking.budgetTokens ).toBe( 1500 );
|
|
129
|
+
} );
|
|
130
|
+
|
|
131
|
+
it( 'end-to-end: should FAIL validation with snake_case budget_tokens', () => {
|
|
132
|
+
// End-to-end test: using snake_case should fail validation
|
|
133
|
+
const promptContent = `---
|
|
134
|
+
provider: anthropic
|
|
135
|
+
model: claude-sonnet-4-20250514
|
|
136
|
+
providerOptions:
|
|
137
|
+
thinking:
|
|
138
|
+
type: enabled
|
|
139
|
+
budget_tokens: 1500
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
<user>Hello</user>`;
|
|
143
|
+
|
|
144
|
+
loadContent.mockReturnValue( promptContent );
|
|
145
|
+
|
|
146
|
+
// loadPrompt should throw ValidationError because schema rejects budget_tokens
|
|
147
|
+
expect( () => {
|
|
148
|
+
loadPrompt( 'test', {} );
|
|
149
|
+
} ).toThrow( /Invalid prompt file/ );
|
|
150
|
+
} );
|
|
151
|
+
|
|
152
|
+
it( 'end-to-end: should FAIL validation with snake_case max_tokens', () => {
|
|
153
|
+
// End-to-end test: using snake_case should fail validation
|
|
154
|
+
const promptContent = `---
|
|
155
|
+
provider: anthropic
|
|
156
|
+
model: claude-sonnet-4-20250514
|
|
157
|
+
max_tokens: 4000
|
|
158
|
+
providerOptions:
|
|
159
|
+
thinking:
|
|
160
|
+
type: enabled
|
|
161
|
+
budget_tokens: 1500
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
<user>Hello</user>`;
|
|
165
|
+
|
|
166
|
+
loadContent.mockReturnValue( promptContent );
|
|
167
|
+
|
|
168
|
+
// loadPrompt should throw ValidationError because schema rejects max_tokens
|
|
169
|
+
expect( () => {
|
|
170
|
+
loadPrompt( 'test', {} );
|
|
171
|
+
} ).toThrow( /Invalid prompt file/ );
|
|
172
|
+
} );
|
|
173
|
+
|
|
174
|
+
} );
|
|
@@ -11,20 +11,40 @@ export const promptSchema = z.object( {
|
|
|
11
11
|
thinking: z.object( {
|
|
12
12
|
type: z.literal( 'enabled' ),
|
|
13
13
|
budgetTokens: z.number()
|
|
14
|
-
} ).optional()
|
|
15
|
-
} ).optional()
|
|
16
|
-
} ),
|
|
14
|
+
} ).strict().optional()
|
|
15
|
+
} ).strict().optional()
|
|
16
|
+
} ).strict(),
|
|
17
17
|
messages: z.array(
|
|
18
18
|
z.object( {
|
|
19
19
|
role: z.string(),
|
|
20
20
|
content: z.string()
|
|
21
|
-
} )
|
|
21
|
+
} ).strict()
|
|
22
22
|
)
|
|
23
|
-
} );
|
|
23
|
+
} ).strict();
|
|
24
|
+
|
|
25
|
+
const getHintForError = errorMessage => {
|
|
26
|
+
if ( errorMessage.includes( 'max_tokens' ) ) {
|
|
27
|
+
return '\nHint: Use "maxTokens" (camelCase) instead of "max_tokens".';
|
|
28
|
+
}
|
|
29
|
+
if ( errorMessage.includes( 'budget_tokens' ) ) {
|
|
30
|
+
return '\nHint: Use "budgetTokens" (camelCase) instead of "budget_tokens".';
|
|
31
|
+
}
|
|
32
|
+
if ( errorMessage.includes( '"options"' ) ) {
|
|
33
|
+
return '\nHint: Use "providerOptions" instead of "options".';
|
|
34
|
+
}
|
|
35
|
+
return '';
|
|
36
|
+
};
|
|
24
37
|
|
|
25
38
|
export function validatePrompt( prompt ) {
|
|
26
39
|
const result = promptSchema.safeParse( prompt );
|
|
27
40
|
if ( !result.success ) {
|
|
28
|
-
|
|
41
|
+
const promptIdentifier = prompt?.name ? `"${prompt.name}"` : '(unnamed)';
|
|
42
|
+
const errorMessage = z.prettifyError( result.error );
|
|
43
|
+
const hint = getHintForError( errorMessage );
|
|
44
|
+
|
|
45
|
+
throw new ValidationError(
|
|
46
|
+
`Invalid prompt file ${promptIdentifier}: ${errorMessage}${hint}`,
|
|
47
|
+
{ cause: result.error }
|
|
48
|
+
);
|
|
29
49
|
}
|
|
30
50
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ValidationError } from '@output.ai/core';
|
|
3
|
+
import { validatePrompt } from './prompt_validations.js';
|
|
4
|
+
|
|
5
|
+
describe( 'validatePrompt', () => {
|
|
6
|
+
it( 'should validate a correct prompt with all required fields', () => {
|
|
7
|
+
const validPrompt = {
|
|
8
|
+
name: 'test-prompt',
|
|
9
|
+
config: {
|
|
10
|
+
provider: 'anthropic',
|
|
11
|
+
model: 'claude-3-opus-20240229',
|
|
12
|
+
temperature: 0.7,
|
|
13
|
+
maxTokens: 1000
|
|
14
|
+
},
|
|
15
|
+
messages: [
|
|
16
|
+
{
|
|
17
|
+
role: 'user',
|
|
18
|
+
content: 'Hello, world!'
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
expect( () => validatePrompt( validPrompt ) ).not.toThrow();
|
|
24
|
+
} );
|
|
25
|
+
|
|
26
|
+
it( 'should validate a minimal prompt with only required fields', () => {
|
|
27
|
+
const minimalPrompt = {
|
|
28
|
+
name: 'minimal-prompt',
|
|
29
|
+
config: {
|
|
30
|
+
provider: 'openai',
|
|
31
|
+
model: 'gpt-4'
|
|
32
|
+
},
|
|
33
|
+
messages: [
|
|
34
|
+
{
|
|
35
|
+
role: 'system',
|
|
36
|
+
content: 'You are a helpful assistant.'
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
expect( () => validatePrompt( minimalPrompt ) ).not.toThrow();
|
|
42
|
+
} );
|
|
43
|
+
|
|
44
|
+
it( 'should validate a prompt with thinking providerOptions', () => {
|
|
45
|
+
const promptWithThinking = {
|
|
46
|
+
name: 'thinking-prompt',
|
|
47
|
+
config: {
|
|
48
|
+
provider: 'anthropic',
|
|
49
|
+
model: 'claude-3-5-sonnet-20241022',
|
|
50
|
+
providerOptions: {
|
|
51
|
+
thinking: {
|
|
52
|
+
type: 'enabled',
|
|
53
|
+
budgetTokens: 5000
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
messages: [
|
|
58
|
+
{
|
|
59
|
+
role: 'user',
|
|
60
|
+
content: 'Solve this problem.'
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
expect( () => validatePrompt( promptWithThinking ) ).not.toThrow();
|
|
66
|
+
} );
|
|
67
|
+
|
|
68
|
+
it( 'should throw ValidationError when provider is invalid', () => {
|
|
69
|
+
const invalidPrompt = {
|
|
70
|
+
name: 'invalid-provider',
|
|
71
|
+
config: {
|
|
72
|
+
provider: 'invalid-provider',
|
|
73
|
+
model: 'some-model'
|
|
74
|
+
},
|
|
75
|
+
messages: [
|
|
76
|
+
{
|
|
77
|
+
role: 'user',
|
|
78
|
+
content: 'Test'
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
expect( () => validatePrompt( invalidPrompt ) ).toThrow( ValidationError );
|
|
84
|
+
} );
|
|
85
|
+
|
|
86
|
+
it( 'should throw ValidationError when required fields are missing', () => {
|
|
87
|
+
const missingNamePrompt = {
|
|
88
|
+
config: {
|
|
89
|
+
provider: 'anthropic',
|
|
90
|
+
model: 'claude-3-opus-20240229'
|
|
91
|
+
},
|
|
92
|
+
messages: [
|
|
93
|
+
{
|
|
94
|
+
role: 'user',
|
|
95
|
+
content: 'Test'
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
expect( () => validatePrompt( missingNamePrompt ) ).toThrow( ValidationError );
|
|
101
|
+
} );
|
|
102
|
+
|
|
103
|
+
it( 'should throw ValidationError when max tokens is snake_case', () => {
|
|
104
|
+
const maxTokensSnakeCase = {
|
|
105
|
+
name: 'test-prompt',
|
|
106
|
+
config: {
|
|
107
|
+
provider: 'anthropic',
|
|
108
|
+
model: 'claude-3-opus-20240229',
|
|
109
|
+
max_tokens: 4000
|
|
110
|
+
},
|
|
111
|
+
messages: [
|
|
112
|
+
{
|
|
113
|
+
role: 'user',
|
|
114
|
+
content: 'Test'
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
expect( () => validatePrompt( maxTokensSnakeCase ) ).toThrow( ValidationError );
|
|
120
|
+
} );
|
|
121
|
+
} );
|