@output.ai/llm 0.2.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@output.ai/llm",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Framework abstraction to interact with LLM models",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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
  };
@@ -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
  } );
@@ -129,4 +129,21 @@ 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
+
132
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
- throw new ValidationError( `Invalid prompt file: ${z.prettifyError( result.error )}` );
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
+ } );