@output.ai/llm 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/ai_sdk.js +8 -0
- package/src/index.d.ts +10 -13
- package/src/parser.spec.js +63 -0
- package/src/prompt_loader.js +1 -1
- package/src/prompt_loader.spec.js +47 -0
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@output.ai/llm",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Framework abstraction to interact with LLM models",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"@ai-sdk/azure": "2.0.53",
|
|
14
14
|
"@ai-sdk/openai": "2.0.52",
|
|
15
15
|
"@output.ai/core": ">=0.0.1",
|
|
16
|
-
"ai": "5.0.
|
|
16
|
+
"ai": "5.0.52",
|
|
17
17
|
"gray-matter": "4.0.3",
|
|
18
18
|
"liquidjs": "10.22.0"
|
|
19
19
|
},
|
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
|
};
|
|
@@ -62,12 +59,12 @@ export type Prompt = {
|
|
|
62
59
|
* Load a prompt file and render it with variables.
|
|
63
60
|
*
|
|
64
61
|
* @param {string} name - Name of the prompt file (without .prompt extension)
|
|
65
|
-
* @param {Record<string, string | number>} [variables] - Variables to interpolate
|
|
62
|
+
* @param {Record<string, string | number | boolean>} [variables] - Variables to interpolate
|
|
66
63
|
* @returns {Prompt} Loaded and rendered prompt object
|
|
67
64
|
*/
|
|
68
65
|
export function loadPrompt(
|
|
69
66
|
name: string,
|
|
70
|
-
variables?: Record<string, string | number>
|
|
67
|
+
variables?: Record<string, string | number | boolean>
|
|
71
68
|
): Prompt;
|
|
72
69
|
|
|
73
70
|
/**
|
|
@@ -78,13 +75,13 @@ export function loadPrompt(
|
|
|
78
75
|
*
|
|
79
76
|
* @param {object} args - Generation arguments
|
|
80
77
|
* @param {string} args.prompt - Prompt file name
|
|
81
|
-
* @param {Record<string, string | number>} args.variables - Variables to interpolate
|
|
78
|
+
* @param {Record<string, string | number | boolean>} args.variables - Variables to interpolate
|
|
82
79
|
* @returns {Promise<string>} Generated text
|
|
83
80
|
*/
|
|
84
81
|
export function generateText(
|
|
85
82
|
args: {
|
|
86
83
|
prompt: string,
|
|
87
|
-
variables?: Record<string, string | number>
|
|
84
|
+
variables?: Record<string, string | number | boolean>
|
|
88
85
|
}
|
|
89
86
|
): Promise<string>;
|
|
90
87
|
|
|
@@ -96,7 +93,7 @@ export function generateText(
|
|
|
96
93
|
*
|
|
97
94
|
* @param {object} args - Generation arguments
|
|
98
95
|
* @param {string} args.prompt - Prompt file name
|
|
99
|
-
* @param {Record<string, string | number>} args.variables - Variables to interpolate
|
|
96
|
+
* @param {Record<string, string | number | boolean>} args.variables - Variables to interpolate
|
|
100
97
|
* @param {z.ZodObject} args.schema - Output schema
|
|
101
98
|
* @param {string} [args.schemaName] - Output schema name
|
|
102
99
|
* @param {string} [args.schemaDescription] - Output schema description
|
|
@@ -105,7 +102,7 @@ export function generateText(
|
|
|
105
102
|
export function generateObject<TSchema extends z.ZodObject>(
|
|
106
103
|
args: {
|
|
107
104
|
prompt: string,
|
|
108
|
-
variables?: Record<string, string | number>,
|
|
105
|
+
variables?: Record<string, string | number | boolean>,
|
|
109
106
|
schema: TSchema,
|
|
110
107
|
schemaName?: string,
|
|
111
108
|
schemaDescription?: string
|
|
@@ -120,7 +117,7 @@ export function generateObject<TSchema extends z.ZodObject>(
|
|
|
120
117
|
*
|
|
121
118
|
* @param {object} args - Generation arguments
|
|
122
119
|
* @param {string} args.prompt - Prompt file name
|
|
123
|
-
* @param {Record<string, string | number>} args.variables - Variables to interpolate
|
|
120
|
+
* @param {Record<string, string | number | boolean>} args.variables - Variables to interpolate
|
|
124
121
|
* @param {z.ZodType} args.schema - Output schema (array item)
|
|
125
122
|
* @param {string} [args.schemaName] - Output schema name
|
|
126
123
|
* @param {string} [args.schemaDescription] - Output schema description
|
|
@@ -129,7 +126,7 @@ export function generateObject<TSchema extends z.ZodObject>(
|
|
|
129
126
|
export function generateArray<TSchema extends z.ZodType>(
|
|
130
127
|
args: {
|
|
131
128
|
prompt: string,
|
|
132
|
-
variables?: Record<string, string | number>,
|
|
129
|
+
variables?: Record<string, string | number | boolean>,
|
|
133
130
|
schema: TSchema,
|
|
134
131
|
schemaName?: string,
|
|
135
132
|
schemaDescription?: string
|
|
@@ -144,14 +141,14 @@ export function generateArray<TSchema extends z.ZodType>(
|
|
|
144
141
|
*
|
|
145
142
|
* @param {object} args - Generation arguments
|
|
146
143
|
* @param {string} args.prompt - Prompt file name
|
|
147
|
-
* @param {Record<string, string | number>} args.variables - Variables to interpolate
|
|
144
|
+
* @param {Record<string, string | number | boolean>} args.variables - Variables to interpolate
|
|
148
145
|
* @param {string[]} args.enum - Allowed values for the generation
|
|
149
146
|
* @returns {Promise<string>} One of the provided enum values
|
|
150
147
|
*/
|
|
151
148
|
export function generateEnum<const TEnum extends readonly [string, ...string[]]>(
|
|
152
149
|
args: {
|
|
153
150
|
prompt: string,
|
|
154
|
-
variables?: Record<string, string | number>,
|
|
151
|
+
variables?: Record<string, string | number | boolean>,
|
|
155
152
|
enum: TEnum
|
|
156
153
|
}
|
|
157
154
|
): Promise<TEnum[number]>;
|
package/src/parser.spec.js
CHANGED
|
@@ -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
|
@@ -18,7 +18,7 @@ const renderPrompt = ( name, content, values ) => {
|
|
|
18
18
|
* Load a prompt file and render it with variables.
|
|
19
19
|
*
|
|
20
20
|
* @param {string} name - Name of the prompt file (without .prompt extension)
|
|
21
|
-
* @param {Record<string, string | number>} [values] - Variables to interpolate
|
|
21
|
+
* @param {Record<string, string | number | boolean>} [values] - Variables to interpolate
|
|
22
22
|
* @returns {Prompt} Loaded and rendered prompt object
|
|
23
23
|
*/
|
|
24
24
|
export const loadPrompt = ( name, values = {} ) => {
|
|
@@ -129,4 +129,51 @@ temperature: 0.7
|
|
|
129
129
|
expect( result.config.model ).toBe( 'claude-sonnet-4' );
|
|
130
130
|
} );
|
|
131
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
|
+
|
|
132
179
|
} );
|
|
@@ -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
|
+
} );
|