@output.ai/llm 0.2.13 → 0.3.0-dev.pr341-d46aaf1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { generateText, generateArray, generateObject, generateEnum } from './ai_sdk.js';
2
2
  export { loadPrompt } from './prompt_loader.js';
3
- export { tool } from 'ai';
3
+ export { registerProvider, getRegisteredProviders } from './ai_model.js';
4
+ export { tool, Output } from 'ai';
4
5
  export * as ai from 'ai';
@@ -6,7 +6,6 @@ vi.mock( './load_content.js', () => ( {
6
6
  } ) );
7
7
 
8
8
  import { loadContent } from './load_content.js';
9
- import { ValidationError } from '@output.ai/core';
10
9
 
11
10
  describe( 'loadPrompt - validation with real schema', () => {
12
11
  beforeEach( () => {
@@ -14,7 +13,6 @@ describe( 'loadPrompt - validation with real schema', () => {
14
13
  } );
15
14
 
16
15
  it( 'should accept valid camelCase format with providerOptions and budgetTokens', () => {
17
- // Frontmatter uses canonical format: providerOptions.thinking.budgetTokens
18
16
  const promptContent = `---
19
17
  provider: anthropic
20
18
  model: claude-sonnet-4-20250514
@@ -32,14 +30,12 @@ providerOptions:
32
30
 
33
31
  const result = loadPrompt( 'test', {} );
34
32
 
35
- // Validation passes with canonical schema format
36
33
  expect( result.config.providerOptions ).toBeDefined();
37
34
  expect( result.config.providerOptions.thinking.type ).toBe( 'enabled' );
38
35
  expect( result.config.providerOptions.thinking.budgetTokens ).toBe( 1500 );
39
36
  } );
40
37
 
41
- it( 'should REJECT snake_case max_tokens (not canonical)', () => {
42
- // Using snake_case max_tokens should fail validation
38
+ it( 'should accept snake_case max_tokens via config passthrough (no longer strict)', () => {
43
39
  const promptContent = `---
44
40
  provider: anthropic
45
41
  model: claude-sonnet-4-20250514
@@ -50,19 +46,13 @@ max_tokens: 64000
50
46
 
51
47
  loadContent.mockReturnValue( promptContent );
52
48
 
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
49
+ // Config uses passthrough, so max_tokens is accepted (though ignored by SDK)
56
50
  expect( () => {
57
51
  loadPrompt( 'test', {} );
58
- } ).toThrow( ValidationError );
59
- expect( () => {
60
- loadPrompt( 'test', {} );
61
- } ).toThrow( /max_tokens/ );
52
+ } ).not.toThrow();
62
53
  } );
63
54
 
64
- it( 'should REJECT deprecated "options" field (use providerOptions)', () => {
65
- // Using 'options' instead of 'providerOptions' should fail validation
55
+ it( 'should accept "options" field via config passthrough (no longer strict)', () => {
66
56
  const promptContent = `---
67
57
  provider: anthropic
68
58
  model: claude-sonnet-4-20250514
@@ -76,85 +66,16 @@ options:
76
66
 
77
67
  loadContent.mockReturnValue( promptContent );
78
68
 
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
69
+ // Config uses passthrough, so 'options' passes through (though not used)
147
70
  expect( () => {
148
71
  loadPrompt( 'test', {} );
149
- } ).toThrow( /Invalid prompt file/ );
72
+ } ).not.toThrow();
150
73
  } );
151
74
 
152
- it( 'end-to-end: should FAIL validation with snake_case max_tokens', () => {
153
- // End-to-end test: using snake_case should fail validation
75
+ it( 'should accept snake_case budget_tokens in thinking via passthrough', () => {
154
76
  const promptContent = `---
155
77
  provider: anthropic
156
78
  model: claude-sonnet-4-20250514
157
- max_tokens: 4000
158
79
  providerOptions:
159
80
  thinking:
160
81
  type: enabled
@@ -165,10 +86,10 @@ providerOptions:
165
86
 
166
87
  loadContent.mockReturnValue( promptContent );
167
88
 
168
- // loadPrompt should throw ValidationError because schema rejects max_tokens
89
+ // budget_tokens is silently stripped from thinking (unknown field), not rejected
169
90
  expect( () => {
170
91
  loadPrompt( 'test', {} );
171
- } ).toThrow( /Invalid prompt file/ );
92
+ } ).not.toThrow();
172
93
  } );
173
94
 
174
95
  } );
@@ -3,23 +3,18 @@ import { ValidationError, z } from '@output.ai/core';
3
3
  export const promptSchema = z.object( {
4
4
  name: z.string(),
5
5
  config: z.object( {
6
- provider: z.enum( [ 'anthropic', 'azure', 'openai', 'vertex' ] ),
6
+ provider: z.string().min( 1 ),
7
7
  model: z.string(),
8
8
  temperature: z.number().optional(),
9
9
  maxTokens: z.number().optional(),
10
10
  tools: z.record( z.string(), z.object( {} ).passthrough() ).optional(),
11
11
  providerOptions: z.object( {
12
12
  thinking: z.object( {
13
- type: z.literal( 'enabled' ),
14
- budgetTokens: z.number()
15
- } ).optional(),
16
- anthropic: z.record( z.string(), z.unknown() ).optional(),
17
- openai: z.record( z.string(), z.unknown() ).optional(),
18
- azure: z.record( z.string(), z.unknown() ).optional(),
19
- vertex: z.record( z.string(), z.unknown() ).optional(),
20
- google: z.record( z.string(), z.unknown() ).optional()
13
+ type: z.enum( [ 'enabled', 'disabled' ] ),
14
+ budgetTokens: z.number().optional()
15
+ } ).passthrough().optional()
21
16
  } ).passthrough().optional()
22
- } ).strict(),
17
+ } ).passthrough(),
23
18
  messages: z.array(
24
19
  z.object( {
25
20
  role: z.string(),
@@ -28,50 +23,38 @@ export const promptSchema = z.object( {
28
23
  )
29
24
  } ).strict();
30
25
 
31
- const getHintForError = errorMessage => {
32
- if ( errorMessage.includes( 'max_tokens' ) ) {
33
- return '\nHint: Use "maxTokens" (camelCase) instead of "max_tokens".';
34
- }
35
- if ( errorMessage.includes( 'budget_tokens' ) ) {
36
- return '\nHint: Use "budgetTokens" (camelCase) instead of "budget_tokens".';
37
- }
38
- if ( errorMessage.includes( '"options"' ) ) {
39
- return '\nHint: Use "providerOptions" instead of "options".';
40
- }
41
- return '';
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'
42
33
  };
43
34
 
44
- // Known providerOptions fields. Note: these don't map 1:1 to providers.
45
- // - 'google' is used by Vertex provider for Gemini language models
46
- // - 'vertex' is used by Vertex provider for Imagen image models
47
- // - 'anthropic' is used by both Anthropic provider and Vertex Anthropic
48
- const knownProviderOptionsFields = new Set( [
49
- 'thinking',
50
- 'anthropic',
51
- 'openai',
52
- 'azure',
53
- 'vertex',
54
- 'google'
55
- ] );
35
+ function warnSnakeCaseFields( config ) {
36
+ for ( const [ snake, camel ] of Object.entries( SNAKE_CASE_WARNINGS ) ) {
37
+ if ( snake in config ) {
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 && 'budget_tokens' in thinking ) {
43
+ console.warn( '[output-llm] "budget_tokens" found in providerOptions.thinking. Did you mean "budgetTokens"?' );
44
+ }
45
+ }
56
46
 
57
47
  export function validatePrompt( prompt ) {
58
48
  const result = promptSchema.safeParse( prompt );
59
49
  if ( !result.success ) {
60
50
  const promptIdentifier = prompt?.name ? `"${prompt.name}"` : '(unnamed)';
61
51
  const errorMessage = z.prettifyError( result.error );
62
- const hint = getHintForError( errorMessage );
63
52
 
64
53
  throw new ValidationError(
65
- `Invalid prompt file ${promptIdentifier}: ${errorMessage}${hint}`,
54
+ `Invalid prompt file ${promptIdentifier}: ${errorMessage}`,
66
55
  { cause: result.error }
67
56
  );
68
57
  }
69
58
 
70
- const providerOptions = prompt?.config?.providerOptions;
71
- if ( providerOptions ) {
72
- const unknownFields = Object.keys( providerOptions ).filter( k => !knownProviderOptionsFields.has( k ) );
73
- if ( unknownFields.length > 0 ) {
74
- console.warn( `Prompt "${prompt.name}": Unrecognized providerOptions fields: ${unknownFields.join( ', ' )}` );
75
- }
76
- }
59
+ warnSnakeCaseFields( result.data.config );
77
60
  }
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect, vi } from 'vitest';
2
2
  import { ValidationError } from '@output.ai/core';
3
3
  import { validatePrompt } from './prompt_validations.js';
4
4
 
@@ -65,6 +65,52 @@ describe( 'validatePrompt', () => {
65
65
  expect( () => validatePrompt( promptWithThinking ) ).not.toThrow();
66
66
  } );
67
67
 
68
+ it( 'should validate a prompt with thinking type disabled', () => {
69
+ const promptWithThinkingDisabled = {
70
+ name: 'thinking-disabled-prompt',
71
+ config: {
72
+ provider: 'anthropic',
73
+ model: 'claude-3-5-sonnet-20241022',
74
+ providerOptions: {
75
+ thinking: {
76
+ type: 'disabled'
77
+ }
78
+ }
79
+ },
80
+ messages: [
81
+ {
82
+ role: 'user',
83
+ content: 'Simple task.'
84
+ }
85
+ ]
86
+ };
87
+
88
+ expect( () => validatePrompt( promptWithThinkingDisabled ) ).not.toThrow();
89
+ } );
90
+
91
+ it( 'should validate a prompt with thinking without budgetTokens', () => {
92
+ const promptWithThinkingNoBudget = {
93
+ name: 'thinking-no-budget',
94
+ config: {
95
+ provider: 'anthropic',
96
+ model: 'claude-3-5-sonnet-20241022',
97
+ providerOptions: {
98
+ thinking: {
99
+ type: 'enabled'
100
+ }
101
+ }
102
+ },
103
+ messages: [
104
+ {
105
+ role: 'user',
106
+ content: 'Think about this.'
107
+ }
108
+ ]
109
+ };
110
+
111
+ expect( () => validatePrompt( promptWithThinkingNoBudget ) ).not.toThrow();
112
+ } );
113
+
68
114
  it( 'should validate a prompt with anthropic-specific providerOptions', () => {
69
115
  const promptWithAnthropicOptions = {
70
116
  name: 'anthropic-options-prompt',
@@ -174,11 +220,50 @@ describe( 'validatePrompt', () => {
174
220
  expect( () => validatePrompt( promptWithMixedOptions ) ).not.toThrow();
175
221
  } );
176
222
 
177
- it( 'should throw ValidationError when provider is invalid', () => {
178
- const invalidPrompt = {
179
- name: 'invalid-provider',
223
+ it( 'should accept custom provider names for dynamic providers', () => {
224
+ const customProviderPrompt = {
225
+ name: 'custom-provider-prompt',
226
+ config: {
227
+ provider: 'my-custom-provider',
228
+ model: 'custom-model-v1'
229
+ },
230
+ messages: [
231
+ {
232
+ role: 'user',
233
+ content: 'Test'
234
+ }
235
+ ]
236
+ };
237
+
238
+ expect( () => validatePrompt( customProviderPrompt ) ).not.toThrow();
239
+ } );
240
+
241
+ it( 'should accept extra config fields via passthrough', () => {
242
+ const extraFieldsPrompt = {
243
+ name: 'extra-fields-prompt',
244
+ config: {
245
+ provider: 'openai',
246
+ model: 'gpt-4',
247
+ topP: 0.9,
248
+ seed: 42,
249
+ stopSequences: [ 'END' ]
250
+ },
251
+ messages: [
252
+ {
253
+ role: 'user',
254
+ content: 'Test'
255
+ }
256
+ ]
257
+ };
258
+
259
+ expect( () => validatePrompt( extraFieldsPrompt ) ).not.toThrow();
260
+ } );
261
+
262
+ it( 'should throw ValidationError when provider is empty string', () => {
263
+ const emptyProviderPrompt = {
264
+ name: 'empty-provider',
180
265
  config: {
181
- provider: 'invalid-provider',
266
+ provider: '',
182
267
  model: 'some-model'
183
268
  },
184
269
  messages: [
@@ -189,7 +274,7 @@ describe( 'validatePrompt', () => {
189
274
  ]
190
275
  };
191
276
 
192
- expect( () => validatePrompt( invalidPrompt ) ).toThrow( ValidationError );
277
+ expect( () => validatePrompt( emptyProviderPrompt ) ).toThrow( ValidationError );
193
278
  } );
194
279
 
195
280
  it( 'should throw ValidationError when required fields are missing', () => {
@@ -209,7 +294,38 @@ describe( 'validatePrompt', () => {
209
294
  expect( () => validatePrompt( missingNamePrompt ) ).toThrow( ValidationError );
210
295
  } );
211
296
 
212
- it( 'should throw ValidationError when max tokens is snake_case', () => {
297
+ it( 'should pass through budget_tokens in thinking and warn about snake_case', () => {
298
+ const warnSpy = vi.spyOn( console, 'warn' ).mockImplementation( () => {} );
299
+
300
+ const promptWithBudgetTokensSnake = {
301
+ name: 'thinking-budget-snake',
302
+ config: {
303
+ provider: 'anthropic',
304
+ model: 'claude-sonnet-4-20250514',
305
+ providerOptions: {
306
+ thinking: {
307
+ type: 'enabled',
308
+ budget_tokens: 10000
309
+ }
310
+ }
311
+ },
312
+ messages: [
313
+ {
314
+ role: 'user',
315
+ content: 'Think hard.'
316
+ }
317
+ ]
318
+ };
319
+
320
+ expect( () => validatePrompt( promptWithBudgetTokensSnake ) ).not.toThrow();
321
+ expect( warnSpy ).toHaveBeenCalledWith(
322
+ '[output-llm] "budget_tokens" found in providerOptions.thinking. Did you mean "budgetTokens"?'
323
+ );
324
+
325
+ warnSpy.mockRestore();
326
+ } );
327
+
328
+ it( 'should allow snake_case fields in config via passthrough (no longer strict)', () => {
213
329
  const maxTokensSnakeCase = {
214
330
  name: 'test-prompt',
215
331
  config: {
@@ -225,6 +341,6 @@ describe( 'validatePrompt', () => {
225
341
  ]
226
342
  };
227
343
 
228
- expect( () => validatePrompt( maxTokensSnakeCase ) ).toThrow( ValidationError );
344
+ expect( () => validatePrompt( maxTokensSnakeCase ) ).not.toThrow();
229
345
  } );
230
346
  } );
@@ -5,17 +5,7 @@ const generateTextArgsSchema = z.object( {
5
5
  variables: z.any().optional()
6
6
  } );
7
7
 
8
- const generateObjectArgsSchema = z.object( {
9
- prompt: z.string(),
10
- variables: z.any().optional(),
11
- schema: z.custom( v => v instanceof z.ZodObject, {
12
- message: 'schema must be a ZodObject'
13
- } ),
14
- schemaName: z.string().optional(),
15
- schemaDescription: z.string().optional()
16
- } );
17
-
18
- const generateArrayArgsSchema = z.object( {
8
+ const generateSchemaArgsSchema = z.object( {
19
9
  prompt: z.string(),
20
10
  variables: z.any().optional(),
21
11
  schema: z.custom( v => v instanceof z.ZodType, {
@@ -43,11 +33,11 @@ export function validateGenerateTextArgs( args ) {
43
33
  }
44
34
 
45
35
  export function validateGenerateObjectArgs( args ) {
46
- validateSchema( generateObjectArgsSchema, args, 'Invalid generateObject() arguments' );
36
+ validateSchema( generateSchemaArgsSchema, args, 'Invalid generateObject() arguments' );
47
37
  }
48
38
 
49
39
  export function validateGenerateArrayArgs( args ) {
50
- validateSchema( generateArrayArgsSchema, args, 'Invalid generateArray() arguments' );
40
+ validateSchema( generateSchemaArgsSchema, args, 'Invalid generateArray() arguments' );
51
41
  }
52
42
 
53
43
  export function validateGenerateEnumArgs( args ) {