@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.
@@ -0,0 +1,346 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { ValidationError } from '@outputai/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 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
+
114
+ it( 'should validate a prompt with anthropic-specific providerOptions', () => {
115
+ const promptWithAnthropicOptions = {
116
+ name: 'anthropic-options-prompt',
117
+ config: {
118
+ provider: 'anthropic',
119
+ model: 'claude-sonnet-4-20250514',
120
+ providerOptions: {
121
+ thinking: {
122
+ type: 'enabled',
123
+ budgetTokens: 5000
124
+ },
125
+ anthropic: {
126
+ effort: 'medium',
127
+ customOption: 'value'
128
+ }
129
+ }
130
+ },
131
+ messages: [
132
+ {
133
+ role: 'user',
134
+ content: 'Solve this problem.'
135
+ }
136
+ ]
137
+ };
138
+
139
+ expect( () => validatePrompt( promptWithAnthropicOptions ) ).not.toThrow();
140
+ } );
141
+
142
+ it( 'should validate a prompt with openai-specific providerOptions', () => {
143
+ const promptWithOpenAIOptions = {
144
+ name: 'openai-options-prompt',
145
+ config: {
146
+ provider: 'openai',
147
+ model: 'o3-mini',
148
+ providerOptions: {
149
+ openai: {
150
+ reasoningEffort: 'high',
151
+ reasoningSummary: 'detailed',
152
+ customParameter: 'test'
153
+ }
154
+ }
155
+ },
156
+ messages: [
157
+ {
158
+ role: 'user',
159
+ content: 'Analyze this data.'
160
+ }
161
+ ]
162
+ };
163
+
164
+ expect( () => validatePrompt( promptWithOpenAIOptions ) ).not.toThrow();
165
+ } );
166
+
167
+ it( 'should validate a prompt with azure-specific providerOptions', () => {
168
+ const promptWithAzureOptions = {
169
+ name: 'azure-options-prompt',
170
+ config: {
171
+ provider: 'azure',
172
+ model: 'gpt-4',
173
+ providerOptions: {
174
+ azure: {
175
+ deploymentName: 'my-deployment',
176
+ customConfig: { key: 'value' }
177
+ }
178
+ }
179
+ },
180
+ messages: [
181
+ {
182
+ role: 'user',
183
+ content: 'Process this request.'
184
+ }
185
+ ]
186
+ };
187
+
188
+ expect( () => validatePrompt( promptWithAzureOptions ) ).not.toThrow();
189
+ } );
190
+
191
+ it( 'should validate a prompt with mixed providerOptions including unknown fields', () => {
192
+ const promptWithMixedOptions = {
193
+ name: 'mixed-options-prompt',
194
+ config: {
195
+ provider: 'anthropic',
196
+ model: 'claude-3-opus-20240229',
197
+ providerOptions: {
198
+ thinking: {
199
+ type: 'enabled',
200
+ budgetTokens: 3000
201
+ },
202
+ anthropic: {
203
+ effort: 'high'
204
+ },
205
+ customProviderField: 'should-be-allowed',
206
+ anotherCustomField: {
207
+ nested: 'value',
208
+ array: [ 1, 2, 3 ]
209
+ }
210
+ }
211
+ },
212
+ messages: [
213
+ {
214
+ role: 'user',
215
+ content: 'Complex request with multiple options.'
216
+ }
217
+ ]
218
+ };
219
+
220
+ expect( () => validatePrompt( promptWithMixedOptions ) ).not.toThrow();
221
+ } );
222
+
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',
265
+ config: {
266
+ provider: '',
267
+ model: 'some-model'
268
+ },
269
+ messages: [
270
+ {
271
+ role: 'user',
272
+ content: 'Test'
273
+ }
274
+ ]
275
+ };
276
+
277
+ expect( () => validatePrompt( emptyProviderPrompt ) ).toThrow( ValidationError );
278
+ } );
279
+
280
+ it( 'should throw ValidationError when required fields are missing', () => {
281
+ const missingNamePrompt = {
282
+ config: {
283
+ provider: 'anthropic',
284
+ model: 'claude-3-opus-20240229'
285
+ },
286
+ messages: [
287
+ {
288
+ role: 'user',
289
+ content: 'Test'
290
+ }
291
+ ]
292
+ };
293
+
294
+ expect( () => validatePrompt( missingNamePrompt ) ).toThrow( ValidationError );
295
+ } );
296
+
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)', () => {
329
+ const maxTokensSnakeCase = {
330
+ name: 'test-prompt',
331
+ config: {
332
+ provider: 'anthropic',
333
+ model: 'claude-3-opus-20240229',
334
+ max_tokens: 4000
335
+ },
336
+ messages: [
337
+ {
338
+ role: 'user',
339
+ content: 'Test'
340
+ }
341
+ ]
342
+ };
343
+
344
+ expect( () => validatePrompt( maxTokensSnakeCase ) ).not.toThrow();
345
+ } );
346
+ } );
@@ -0,0 +1,49 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ /**
4
+ * Checks whether a tool result looks like a search response (has a `results` array with `url` strings).
5
+ */
6
+ const isSearchResult = result => !!result?.results?.[0]?.url;
7
+
8
+ const toSource = ( { url, title } ) => ( {
9
+ type: 'source',
10
+ sourceType: 'url',
11
+ id: createHash( 'sha256' ).update( url ).digest( 'hex' ).slice( 0, 16 ),
12
+ url,
13
+ title: title ?? ''
14
+ } );
15
+
16
+ /**
17
+ * Extracts source URLs from search tool results embedded in AI SDK step data.
18
+ *
19
+ * Detects any tool result containing a `results[]` array whose items have a `url` string field.
20
+ * This covers perplexitySearch, tavilySearch, exaSearch, and any future tool with the same shape.
21
+ *
22
+ * Best-effort: returns empty array on any error rather than throwing.
23
+ *
24
+ * @param {Array} steps - AI SDK response steps (response.steps)
25
+ * @returns {Array<{ type: string, sourceType: string, id: string, url: string, title: string }>}
26
+ */
27
+ export function extractSourcesFromSteps( steps ) {
28
+ try {
29
+ if ( !Array.isArray( steps ) || steps.length === 0 ) {
30
+ return [];
31
+ }
32
+
33
+ const seen = new Set();
34
+ return steps
35
+ .flatMap( step => Array.isArray( step.toolResults ) ? step.toolResults : [] )
36
+ .flatMap( toolResult => isSearchResult( toolResult.output ) ? toolResult.output.results : [] )
37
+ .filter( item => {
38
+ if ( !item.url || seen.has( item.url ) ) {
39
+ return false;
40
+ }
41
+ seen.add( item.url );
42
+ return true;
43
+ } )
44
+ .map( toSource );
45
+ } catch ( error ) {
46
+ console.warn( '[output-llm] source extraction failed, returning empty sources', error );
47
+ return [];
48
+ }
49
+ }
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractSourcesFromSteps } from './source_extraction.js';
3
+
4
+ describe( 'extractSourcesFromSteps', () => {
5
+ it( 'returns empty array for undefined/null/empty steps', () => {
6
+ expect( extractSourcesFromSteps( undefined ) ).toEqual( [] );
7
+ expect( extractSourcesFromSteps( null ) ).toEqual( [] );
8
+ expect( extractSourcesFromSteps( [] ) ).toEqual( [] );
9
+ } );
10
+
11
+ it( 'skips steps with no toolResults', () => {
12
+ const steps = [ { text: 'hello' } ];
13
+ expect( extractSourcesFromSteps( steps ) ).toEqual( [] );
14
+ } );
15
+
16
+ it( 'skips non-search tool results', () => {
17
+ const steps = [ {
18
+ toolResults: [ {
19
+ output: { answer: 'some text', confidence: 0.9 }
20
+ } ]
21
+ } ];
22
+ expect( extractSourcesFromSteps( steps ) ).toEqual( [] );
23
+ } );
24
+
25
+ it( 'skips tool results with empty results array', () => {
26
+ const steps = [ {
27
+ toolResults: [ { output: { results: [] } } ]
28
+ } ];
29
+ expect( extractSourcesFromSteps( steps ) ).toEqual( [] );
30
+ } );
31
+
32
+ it( 'skips tool results where results items lack url', () => {
33
+ const steps = [ {
34
+ toolResults: [ {
35
+ output: { results: [ { title: 'no url', content: 'text' } ] }
36
+ } ]
37
+ } ];
38
+ expect( extractSourcesFromSteps( steps ) ).toEqual( [] );
39
+ } );
40
+
41
+ it( 'extracts from perplexity-shaped results', () => {
42
+ const steps = [ {
43
+ toolResults: [ {
44
+ output: {
45
+ id: 'pplx-123',
46
+ results: [
47
+ { url: 'https://example.com/1', title: 'Example 1', snippet: 'text' },
48
+ { url: 'https://example.com/2', title: 'Example 2', snippet: 'text' }
49
+ ]
50
+ }
51
+ } ]
52
+ } ];
53
+
54
+ const sources = extractSourcesFromSteps( steps );
55
+ expect( sources ).toHaveLength( 2 );
56
+ expect( sources[0] ).toEqual( {
57
+ type: 'source',
58
+ sourceType: 'url',
59
+ id: expect.any( String ),
60
+ url: 'https://example.com/1',
61
+ title: 'Example 1'
62
+ } );
63
+ expect( sources[1].url ).toBe( 'https://example.com/2' );
64
+ } );
65
+
66
+ it( 'extracts from tavily-shaped results', () => {
67
+ const steps = [ {
68
+ toolResults: [ {
69
+ output: {
70
+ query: 'test query',
71
+ results: [
72
+ { url: 'https://tavily.com/a', title: 'Tavily A', content: 'stuff', score: 0.95 }
73
+ ]
74
+ }
75
+ } ]
76
+ } ];
77
+
78
+ const sources = extractSourcesFromSteps( steps );
79
+ expect( sources ).toHaveLength( 1 );
80
+ expect( sources[0].url ).toBe( 'https://tavily.com/a' );
81
+ expect( sources[0].title ).toBe( 'Tavily A' );
82
+ } );
83
+
84
+ it( 'extracts from exa-shaped results', () => {
85
+ const steps = [ {
86
+ toolResults: [ {
87
+ output: {
88
+ results: [
89
+ { url: 'https://exa.ai/r', title: 'Exa Result', text: 'content', summary: 'summary' }
90
+ ]
91
+ }
92
+ } ]
93
+ } ];
94
+
95
+ const sources = extractSourcesFromSteps( steps );
96
+ expect( sources ).toHaveLength( 1 );
97
+ expect( sources[0].url ).toBe( 'https://exa.ai/r' );
98
+ expect( sources[0].title ).toBe( 'Exa Result' );
99
+ } );
100
+
101
+ it( 'deduplicates by URL across multiple steps', () => {
102
+ const steps = [
103
+ {
104
+ toolResults: [ {
105
+ output: { results: [ { url: 'https://dup.com', title: 'First' } ] }
106
+ } ]
107
+ },
108
+ {
109
+ toolResults: [ {
110
+ output: { results: [ { url: 'https://dup.com', title: 'Second' } ] }
111
+ } ]
112
+ }
113
+ ];
114
+
115
+ const sources = extractSourcesFromSteps( steps );
116
+ expect( sources ).toHaveLength( 1 );
117
+ expect( sources[0].title ).toBe( 'First' );
118
+ } );
119
+
120
+ it( 'deduplicates by URL within the same step', () => {
121
+ const steps = [ {
122
+ toolResults: [
123
+ { output: { results: [ { url: 'https://same.com', title: 'A' } ] } },
124
+ { output: { results: [ { url: 'https://same.com', title: 'B' } ] } }
125
+ ]
126
+ } ];
127
+
128
+ const sources = extractSourcesFromSteps( steps );
129
+ expect( sources ).toHaveLength( 1 );
130
+ } );
131
+
132
+ it( 'handles mixed search and non-search tool results', () => {
133
+ const steps = [ {
134
+ toolResults: [
135
+ { output: { calculation: 42 } },
136
+ { output: { results: [ { url: 'https://real.com', title: 'Real' } ] } },
137
+ { output: 'plain string' }
138
+ ]
139
+ } ];
140
+
141
+ const sources = extractSourcesFromSteps( steps );
142
+ expect( sources ).toHaveLength( 1 );
143
+ expect( sources[0].url ).toBe( 'https://real.com' );
144
+ } );
145
+
146
+ it( 'defaults title to empty string when missing', () => {
147
+ const steps = [ {
148
+ toolResults: [ {
149
+ output: { results: [ { url: 'https://notitle.com' } ] }
150
+ } ]
151
+ } ];
152
+
153
+ const sources = extractSourcesFromSteps( steps );
154
+ expect( sources[0].title ).toBe( '' );
155
+ } );
156
+
157
+ it( 'generates deterministic id from URL', () => {
158
+ const steps = [ {
159
+ toolResults: [ {
160
+ output: { results: [ { url: 'https://stable.com/path', title: 'S' } ] }
161
+ } ]
162
+ } ];
163
+
164
+ const s1 = extractSourcesFromSteps( steps );
165
+ const s2 = extractSourcesFromSteps( steps );
166
+ expect( s1[0].id ).toBe( s2[0].id );
167
+ expect( s1[0].id ).toHaveLength( 16 );
168
+ } );
169
+ } );
@@ -0,0 +1,21 @@
1
+ import { ValidationError, z } from '@outputai/core';
2
+
3
+ const generateTextArgsSchema = z.object( {
4
+ prompt: z.string(),
5
+ variables: z.any().optional()
6
+ } );
7
+
8
+ function validateSchema( schema, input, errorPrefix ) {
9
+ const result = schema.safeParse( input );
10
+ if ( !result.success ) {
11
+ throw new ValidationError( `${errorPrefix}: ${z.prettifyError( result.error )}` );
12
+ }
13
+ }
14
+
15
+ export function validateGenerateTextArgs( args ) {
16
+ validateSchema( generateTextArgsSchema, args, 'Invalid generateText() arguments' );
17
+ }
18
+
19
+ export function validateStreamTextArgs( args ) {
20
+ validateSchema( generateTextArgsSchema, args, 'Invalid streamText() arguments' );
21
+ }