@outputai/llm 0.6.1-dev.daae905.0 → 0.6.1-next.0d08ff5.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.
Files changed (45) hide show
  1. package/package.json +2 -2
  2. package/src/agent.js +15 -9
  3. package/src/agent.spec.js +295 -214
  4. package/src/ai_model.js +79 -36
  5. package/src/ai_model.spec.js +31 -13
  6. package/src/ai_sdk.js +55 -79
  7. package/src/ai_sdk.spec.js +464 -611
  8. package/src/ai_sdk_options.js +61 -0
  9. package/src/ai_sdk_options.spec.js +164 -0
  10. package/src/cost/index.js +1 -1
  11. package/src/index.d.ts +230 -175
  12. package/src/index.js +2 -2
  13. package/src/prompt/escape.js +65 -0
  14. package/src/prompt/escape.spec.js +159 -0
  15. package/src/{load_content.js → prompt/load_content.js} +1 -22
  16. package/src/{load_content.spec.js → prompt/load_content.spec.js} +6 -6
  17. package/src/prompt/loader.js +49 -0
  18. package/src/prompt/loader.spec.js +274 -0
  19. package/src/{prompt_loader_validation.spec.js → prompt/loader_validation.spec.js} +40 -7
  20. package/src/prompt/parser.js +19 -0
  21. package/src/{parser.spec.js → prompt/parser.spec.js} +74 -29
  22. package/src/prompt/prepare_text.js +27 -0
  23. package/src/prompt/prepare_text.spec.js +141 -0
  24. package/src/{skill.js → prompt/skill.js} +19 -0
  25. package/src/prompt/skill.spec.js +172 -0
  26. package/src/{prompt_validations.js → prompt/validations.js} +32 -6
  27. package/src/{prompt_validations.spec.js → prompt/validations.spec.js} +189 -1
  28. package/src/utils/__fixtures__/image_response.json +38 -0
  29. package/src/utils/__fixtures__/stream_response.json +294 -0
  30. package/src/utils/__fixtures__/text_response.json +201 -0
  31. package/src/utils/error_handler.js +65 -0
  32. package/src/utils/error_handler.spec.js +195 -0
  33. package/src/utils/image.js +10 -0
  34. package/src/utils/image.spec.js +20 -0
  35. package/src/utils/response_wrappers.js +46 -19
  36. package/src/utils/response_wrappers.spec.js +130 -70
  37. package/src/utils/source_extraction.js +17 -27
  38. package/src/utils/trace.js +2 -3
  39. package/src/utils/trace.spec.js +9 -13
  40. package/src/validations.js +54 -2
  41. package/src/validations.spec.js +166 -0
  42. package/src/parser.js +0 -28
  43. package/src/prompt_loader.js +0 -80
  44. package/src/prompt_loader.spec.js +0 -358
  45. package/src/skill.d.ts +0 -49
@@ -1,18 +1,22 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { generateImage } from 'ai';
3
+ import { MockImageModelV3 } from 'ai/test';
4
+ import textResponseFixture from './__fixtures__/text_response.json' with { type: 'json' };
5
+ import streamResponseFixture from './__fixtures__/stream_response.json' with { type: 'json' };
6
+ import imageResponseFixture from './__fixtures__/image_response.json' with { type: 'json' };
2
7
 
3
8
  const mocks = vi.hoisted( () => ( {
9
+ combineSources: vi.fn(),
4
10
  extractSourcesFromSteps: vi.fn(),
5
11
  calculateLLMCallCost: vi.fn(),
6
- endTraceWithSuccess: vi.fn()
12
+ endTraceWithSuccess: vi.fn(),
13
+ calculateBase64FileSize: vi.fn()
7
14
  } ) );
8
15
 
9
- vi.mock( './source_extraction.js', async importOriginal => {
10
- const actual = await importOriginal();
11
- return {
12
- ...actual,
13
- extractSourcesFromSteps: mocks.extractSourcesFromSteps
14
- };
15
- } );
16
+ vi.mock( './source_extraction.js', () => ( {
17
+ combineSources: mocks.combineSources,
18
+ extractSourcesFromSteps: mocks.extractSourcesFromSteps
19
+ } ) );
16
20
 
17
21
  vi.mock( '../cost/index.js', () => ( {
18
22
  calculateLLMCallCost: mocks.calculateLLMCallCost
@@ -22,7 +26,29 @@ vi.mock( './trace.js', () => ( {
22
26
  endTraceWithSuccess: mocks.endTraceWithSuccess
23
27
  } ) );
24
28
 
25
- import { wrapTextResponse, wrapStreamOnFinishResponse } from './response_wrappers.js';
29
+ vi.mock( './image.js', () => ( {
30
+ calculateBase64FileSize: mocks.calculateBase64FileSize
31
+ } ) );
32
+
33
+ import { wrapTextResponse, wrapStreamOnFinishResponse, wrapImageResponse } from './response_wrappers.js';
34
+
35
+ const clone = value => structuredClone( value );
36
+
37
+ const makeAiSdkImageResponse = () => generateImage( {
38
+ model: new MockImageModelV3( {
39
+ doGenerate: async () => ( {
40
+ images: imageResponseFixture.images.map( image => Buffer.from( image.base64Data, 'base64' ) ),
41
+ warnings: imageResponseFixture.warnings,
42
+ response: {
43
+ ...imageResponseFixture.responses[0],
44
+ timestamp: new Date( imageResponseFixture.responses[0].timestamp )
45
+ },
46
+ providerMetadata: imageResponseFixture.providerMetadata,
47
+ usage: imageResponseFixture.usage
48
+ } )
49
+ } ),
50
+ prompt: 'fixture prompt'
51
+ } );
26
52
 
27
53
  describe( 'wrapTextResponse', () => {
28
54
  const traceId = 'trace-1';
@@ -33,53 +59,47 @@ describe( 'wrapTextResponse', () => {
33
59
  vi.clearAllMocks();
34
60
  mocks.extractSourcesFromSteps.mockReturnValue( [] );
35
61
  mocks.calculateLLMCallCost.mockResolvedValue( mockCost );
62
+ mocks.combineSources.mockReturnValue( [] );
36
63
  } );
37
64
 
38
- it( 'proxies result to text and attaches cost', async () => {
39
- const response = {
40
- text: 'hello',
41
- totalUsage: { inputTokens: 10, outputTokens: 5 },
42
- steps: [],
43
- sources: []
44
- };
65
+ it( 'uses a text response fixture to calculate cost, end trace, and attach cost', async () => {
66
+ const response = clone( textResponseFixture );
45
67
 
46
68
  const wrapped = await wrapTextResponse( { traceId, modelId, response } );
47
69
 
48
- expect( wrapped.result ).toBe( 'hello' );
49
- expect( wrapped.text ).toBe( 'hello' );
70
+ expect( wrapped.result ).toBe( response.text );
50
71
  expect( wrapped.cost ).toEqual( mockCost );
51
72
  expect( mocks.calculateLLMCallCost ).toHaveBeenCalledWith( {
52
73
  usage: response.totalUsage,
53
74
  modelId
54
75
  } );
76
+ expect( mocks.extractSourcesFromSteps ).toHaveBeenCalledWith( response.steps );
55
77
  expect( mocks.endTraceWithSuccess ).toHaveBeenCalledWith( {
56
78
  traceId,
57
- modelId,
58
- response,
79
+ usage: response.totalUsage,
59
80
  cost: mockCost,
81
+ result: response.text,
82
+ providerMetadata: response.providerMetadata,
60
83
  sourcesFromTools: []
61
84
  } );
62
85
  } );
63
86
 
64
- it( 'leaves sources unchanged when no tool-extracted sources (same as raw response)', async () => {
87
+ it( 'leaves sources unchanged when no tool-extracted sources are found', async () => {
88
+ const response = clone( streamResponseFixture );
65
89
  const nativeSources = [
66
90
  { type: 'source', sourceType: 'url', id: 'n1', url: 'https://native.test', title: 'Native' }
67
91
  ];
92
+ response.sources = nativeSources;
68
93
  mocks.extractSourcesFromSteps.mockReturnValue( [] );
69
94
 
70
- const response = {
71
- text: 'x',
72
- totalUsage: {},
73
- steps: [],
74
- sources: nativeSources
75
- };
76
-
77
95
  const wrapped = await wrapTextResponse( { traceId, modelId, response } );
78
96
 
79
97
  expect( wrapped.sources ).toBe( nativeSources );
98
+ expect( mocks.combineSources ).not.toHaveBeenCalled();
80
99
  } );
81
100
 
82
- it( 'merges tool-extracted sources with response sources by url when tool sources exist', async () => {
101
+ it( 'delegates source merging when tool-extracted sources exist', async () => {
102
+ const response = clone( streamResponseFixture );
83
103
  const toolSource = {
84
104
  type: 'source',
85
105
  sourceType: 'url',
@@ -94,40 +114,18 @@ describe( 'wrapTextResponse', () => {
94
114
  url: 'https://example.com/b',
95
115
  title: 'B'
96
116
  };
117
+ const mergedSources = [ toolSource, responseSource ];
118
+ response.sources = [ responseSource ];
97
119
  mocks.extractSourcesFromSteps.mockReturnValue( [ toolSource ] );
98
-
99
- const response = {
100
- text: 'x',
101
- totalUsage: {},
102
- steps: [ {} ],
103
- sources: [ responseSource ]
104
- };
105
-
106
- const wrapped = await wrapTextResponse( { traceId, modelId, response } );
107
-
108
- expect( wrapped.sources ).toEqual( expect.arrayContaining( [ toolSource, responseSource ] ) );
109
- expect( wrapped.sources ).toHaveLength( 2 );
110
- } );
111
-
112
- it( 'when tool sources overlap urls, later entry in merge wins', async () => {
113
- const url = 'https://example.com/same';
114
- mocks.extractSourcesFromSteps.mockReturnValue( [
115
- { type: 'source', sourceType: 'url', id: '1', url, title: 'from-tool' }
116
- ] );
117
-
118
- const response = {
119
- text: 'x',
120
- totalUsage: {},
121
- steps: [],
122
- sources: [
123
- { type: 'source', sourceType: 'url', id: '2', url, title: 'from-response' }
124
- ]
125
- };
120
+ mocks.combineSources.mockReturnValue( mergedSources );
126
121
 
127
122
  const wrapped = await wrapTextResponse( { traceId, modelId, response } );
128
123
 
129
- expect( wrapped.sources ).toHaveLength( 1 );
130
- expect( wrapped.sources[0].title ).toBe( 'from-response' );
124
+ expect( wrapped.sources ).toBe( mergedSources );
125
+ expect( mocks.combineSources ).toHaveBeenCalledWith( {
126
+ sourcesFromTools: [ toolSource ],
127
+ sourcesFromResponse: [ responseSource ]
128
+ } );
131
129
  } );
132
130
  } );
133
131
 
@@ -139,15 +137,12 @@ describe( 'wrapStreamOnFinishResponse', () => {
139
137
  beforeEach( () => {
140
138
  vi.clearAllMocks();
141
139
  mocks.calculateLLMCallCost.mockResolvedValue( mockCost );
140
+ mocks.extractSourcesFromSteps.mockReturnValue( [] );
142
141
  } );
143
142
 
144
- it( 'onFinish finishes trace, proxies result and cost for user callback, and forwards other props', async () => {
143
+ it( 'uses the stream response fixture to finish trace and call the user callback with a proxied response', async () => {
145
144
  const userOnFinish = vi.fn();
146
- const response = {
147
- text: 'done',
148
- totalUsage: { inputTokens: 1 },
149
- finishReason: 'stop'
150
- };
145
+ const response = clone( streamResponseFixture );
151
146
 
152
147
  const callbacks = wrapStreamOnFinishResponse( {
153
148
  traceId,
@@ -159,14 +154,79 @@ describe( 'wrapStreamOnFinishResponse', () => {
159
154
 
160
155
  expect( mocks.endTraceWithSuccess ).toHaveBeenCalledWith( {
161
156
  traceId,
162
- modelId,
163
- response,
164
- cost: mockCost
157
+ usage: response.totalUsage,
158
+ cost: mockCost,
159
+ result: response.text,
160
+ providerMetadata: response.providerMetadata,
161
+ sourcesFromTools: []
165
162
  } );
166
163
  expect( userOnFinish ).toHaveBeenCalledTimes( 1 );
167
164
  const proxied = userOnFinish.mock.calls[0][0];
168
- expect( proxied.result ).toBe( 'done' );
165
+ expect( proxied.result ).toBe( response.text );
169
166
  expect( proxied.cost ).toEqual( mockCost );
170
- expect( proxied.finishReason ).toBe( 'stop' );
167
+ expect( proxied.finishReason ).toBe( response.finishReason );
168
+ expect( mocks.extractSourcesFromSteps ).toHaveBeenCalledWith( response.steps );
169
+ } );
170
+
171
+ it( 'finishes trace even when no user onFinish callback is provided', async () => {
172
+ const response = clone( streamResponseFixture );
173
+
174
+ const callbacks = wrapStreamOnFinishResponse( {
175
+ traceId,
176
+ modelId
177
+ } );
178
+
179
+ await callbacks.onFinish( response );
180
+
181
+ expect( mocks.endTraceWithSuccess ).toHaveBeenCalledWith( {
182
+ traceId,
183
+ usage: response.totalUsage,
184
+ cost: mockCost,
185
+ result: response.text,
186
+ providerMetadata: response.providerMetadata,
187
+ sourcesFromTools: []
188
+ } );
189
+ expect( mocks.calculateLLMCallCost ).toHaveBeenCalledWith( {
190
+ usage: response.totalUsage,
191
+ modelId
192
+ } );
193
+ } );
194
+ } );
195
+
196
+ describe( 'wrapImageResponse', () => {
197
+ const traceId = 'image-trace';
198
+ const modelId = 'image-model';
199
+ const mockCost = { total: 0.003, components: [] };
200
+
201
+ beforeEach( () => {
202
+ vi.clearAllMocks();
203
+ mocks.calculateLLMCallCost.mockResolvedValue( mockCost );
204
+ mocks.calculateBase64FileSize.mockReturnValue( 1234 );
205
+ } );
206
+
207
+ it( 'uses an image response fixture to trace image metadata and attach cost', async () => {
208
+ const response = await makeAiSdkImageResponse();
209
+
210
+ const wrapped = await wrapImageResponse( { traceId, modelId, response } );
211
+
212
+ expect( wrapped.result ).toBe( response.images[0] );
213
+ expect( wrapped.cost ).toEqual( mockCost );
214
+ expect( mocks.calculateLLMCallCost ).toHaveBeenCalledWith( {
215
+ usage: response.usage,
216
+ modelId
217
+ } );
218
+ expect( mocks.calculateBase64FileSize ).toHaveBeenCalledWith( response.images[0].base64 );
219
+ expect( mocks.endTraceWithSuccess ).toHaveBeenCalledWith( {
220
+ traceId,
221
+ usage: response.usage,
222
+ cost: mockCost,
223
+ result: [
224
+ {
225
+ size: 1234,
226
+ mediaType: response.images[0].mediaType
227
+ }
228
+ ],
229
+ providerMetadata: response.providerMetadata
230
+ } );
171
231
  } );
172
232
  } );
@@ -3,9 +3,16 @@ import { createHash } from 'node:crypto';
3
3
  /**
4
4
  * Checks whether a tool result looks like a search response (has a `results` array with `url` strings).
5
5
  */
6
- const isSearchResult = result => !!result?.results?.[0]?.url;
6
+ const isSearchResult = v => Array.isArray( v?.results ) && v.results.some( r => typeof r?.url === 'string' );
7
7
 
8
- const toSource = ( { url, title } ) => ( {
8
+ /**
9
+ * Builds the final source shape
10
+ * @param {object} args
11
+ * @param {string} args.url
12
+ * @param {string} args.title
13
+ * @returns {object} final object
14
+ */
15
+ const buildSource = ( { url, title } ) => ( {
9
16
  type: 'source',
10
17
  sourceType: 'url',
11
18
  id: createHash( 'sha256' ).update( url ).digest( 'hex' ).slice( 0, 16 ),
@@ -19,34 +26,17 @@ const toSource = ( { url, title } ) => ( {
19
26
  * Detects any tool result containing a `results[]` array whose items have a `url` string field.
20
27
  * This covers perplexitySearch, tavilySearch, exaSearch, and any future tool with the same shape.
21
28
  *
22
- * Best-effort: returns empty array on any error rather than throwing.
23
- *
24
29
  * @param {Array} steps - AI SDK response steps (response.steps)
25
30
  * @returns {Array<{ type: string, sourceType: string, id: string, url: string, title: string }>}
26
31
  */
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
- }
32
+ export const extractSourcesFromSteps = steps =>
33
+ ( Array.isArray( steps ) ? steps : [] )
34
+ .flatMap( step => Array.isArray( step?.toolResults ) ? step.toolResults : [] )
35
+ .flatMap( toolResult => isSearchResult( toolResult?.output ) ? toolResult.output.results : [] )
36
+ .filter( item => typeof item?.url === 'string' && item.url.length > 0 ) // Ignore non string or empty string urls
37
+ .reduce( ( map, v ) => map.has( v.url ) ? map : map.set( v.url, v ), new Map() ) // deduplicate, by keeping first entry
38
+ .values().toArray()
39
+ .map( v => buildSource( v ) );
50
40
 
51
41
  /**
52
42
  * Merge sources used tools and sources from AI SDK response into a single list
@@ -10,11 +10,10 @@ export const endTraceWithError = ( { traceId, error } ) => {
10
10
  Tracing.addEventError( { id: traceId, details: error } );
11
11
  };
12
12
 
13
- export const endTraceWithSuccess = ( { traceId, modelId, response, cost, ...extra } ) => {
14
- const { totalUsage: usage, text: result, providerMetadata } = response;
13
+ export const endTraceWithSuccess = ( { traceId, result, cost, ...extra } ) => {
15
14
  if ( cost ) {
16
15
  Tracing.addEventAttribute( { eventId: traceId, attribute: cost } );
17
16
  emitEvent( 'cost:llm:request', cost );
18
17
  }
19
- Tracing.addEventEnd( { id: traceId, details: { result, usage, providerMetadata, ...extra } } );
18
+ Tracing.addEventEnd( { id: traceId, details: { result, ...extra } } );
20
19
  };
@@ -51,19 +51,16 @@ describe( 'trace utils', () => {
51
51
  } );
52
52
 
53
53
  describe( 'endTraceWithSuccess', () => {
54
- it( 'adds cost attribute, emits cost attribute, and ends the trace with response fields and extra details', () => {
54
+ it( 'adds cost attribute, emits cost event, and ends the trace with normalized details', () => {
55
55
  const cost = { type: 'llm:usage', modelId: 'my-model', total: 0.01, usage: [] };
56
56
  const usage = { inputTokens: 2, outputTokens: 3 };
57
- const response = {
58
- text: 'hello',
59
- totalUsage: usage,
60
- providerMetadata: { provider: 'x' }
61
- };
62
57
 
63
58
  endTraceWithSuccess( {
64
59
  traceId: 'trace-a',
65
60
  modelId: 'my-model',
66
- response,
61
+ result: 'hello',
62
+ usage,
63
+ providerMetadata: { provider: 'x' },
67
64
  cost,
68
65
  sourcesFromTools: [ { url: 'https://u.test', title: '' } ]
69
66
  } );
@@ -77,6 +74,7 @@ describe( 'trace utils', () => {
77
74
  id: 'trace-a',
78
75
  details: {
79
76
  result: 'hello',
77
+ modelId: 'my-model',
80
78
  usage,
81
79
  providerMetadata: { provider: 'x' },
82
80
  sourcesFromTools: [ { url: 'https://u.test', title: '' } ]
@@ -86,16 +84,13 @@ describe( 'trace utils', () => {
86
84
 
87
85
  it( 'does not emit or add an attribute when cost is missing', () => {
88
86
  const usage = { inputTokens: 2, outputTokens: 3 };
89
- const response = {
90
- text: 'hello',
91
- totalUsage: usage,
92
- providerMetadata: { provider: 'x' }
93
- };
94
87
 
95
88
  endTraceWithSuccess( {
96
89
  traceId: 'trace-no-cost',
97
90
  modelId: 'my-model',
98
- response
91
+ result: 'hello',
92
+ usage,
93
+ providerMetadata: { provider: 'x' }
99
94
  } );
100
95
 
101
96
  expect( tracing.addEventAttribute ).not.toHaveBeenCalled();
@@ -104,6 +99,7 @@ describe( 'trace utils', () => {
104
99
  id: 'trace-no-cost',
105
100
  details: {
106
101
  result: 'hello',
102
+ modelId: 'my-model',
107
103
  usage,
108
104
  providerMetadata: { provider: 'x' }
109
105
  }
@@ -1,8 +1,56 @@
1
+ import { Buffer } from 'node:buffer';
1
2
  import { ValidationError, z } from '@outputai/core';
2
3
 
4
+ const skillArgSchema = z.object( {
5
+ name: z.string().min( 1 ),
6
+ description: z.string().optional(),
7
+ instructions: z.string().min( 1 )
8
+ } ).strict();
9
+
3
10
  const generateTextArgsSchema = z.object( {
4
- prompt: z.string(),
5
- variables: z.any().optional()
11
+ prompt: z.string().min( 1 ),
12
+ variables: z.any().optional(),
13
+ promptDir: z.string().min( 1 ).optional(),
14
+ skills: z.union( [ z.array( skillArgSchema ), z.function() ] ).optional(),
15
+ maxSteps: z.number().int().positive().optional()
16
+ } );
17
+
18
+ const base64StringSchema = z.string()
19
+ .min( 1 )
20
+ .regex(
21
+ /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=?)?$/,
22
+ 'Image strings must be raw base64 data.'
23
+ );
24
+
25
+ const imageDataSchema = z.union( [
26
+ z.instanceof( Buffer ),
27
+ z.instanceof( Uint8Array ),
28
+ z.instanceof( ArrayBuffer ),
29
+ base64StringSchema
30
+ ] );
31
+
32
+ const imageInputSchema = z.union( [
33
+ imageDataSchema,
34
+ z.object( {
35
+ data: imageDataSchema,
36
+ mediaType: z.string().min( 1 ).optional()
37
+ } ).strict()
38
+ ] );
39
+
40
+ const generateImageArgsSchema = z.object( {
41
+ prompt: z.string().min( 1 ),
42
+ variables: z.any().optional(),
43
+ promptDir: z.string().min( 1 ).optional(),
44
+ images: z.array( imageInputSchema ).min( 1 ).optional(),
45
+ mask: imageInputSchema.optional()
46
+ } ).superRefine( ( args, ctx ) => {
47
+ if ( args.mask && !args.images ) {
48
+ ctx.addIssue( {
49
+ code: 'custom',
50
+ path: [ 'mask' ],
51
+ message: 'mask requires images.'
52
+ } );
53
+ }
6
54
  } );
7
55
 
8
56
  function validateSchema( schema, input, errorPrefix ) {
@@ -19,3 +67,7 @@ export function validateGenerateTextArgs( args ) {
19
67
  export function validateStreamTextArgs( args ) {
20
68
  validateSchema( generateTextArgsSchema, args, 'Invalid streamText() arguments' );
21
69
  }
70
+
71
+ export function validateGenerateImageArgs( args ) {
72
+ validateSchema( generateImageArgsSchema, args, 'Invalid generateImage() arguments' );
73
+ }
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ValidationError } from '@outputai/core';
3
+ import { validateGenerateTextArgs, validateStreamTextArgs, validateGenerateImageArgs } from './validations.js';
4
+
5
+ describe( 'validateGenerateTextArgs', () => {
6
+ it( 'accepts a prompt with optional variables', () => {
7
+ expect( () => validateGenerateTextArgs( {
8
+ prompt: 'summary@v1',
9
+ variables: { topic: 'testing' },
10
+ promptDir: '/prompts',
11
+ skills: [ { name: 'style', description: 'Style', instructions: '# Style' } ],
12
+ maxSteps: 4
13
+ } ) ).not.toThrow();
14
+ } );
15
+
16
+ it( 'accepts a dynamic skills function', () => {
17
+ expect( () => validateGenerateTextArgs( {
18
+ prompt: 'summary@v1',
19
+ skills: () => [ { name: 'style', description: 'Style', instructions: '# Style' } ]
20
+ } ) ).not.toThrow();
21
+ } );
22
+
23
+ it( 'throws ValidationError with generateText prefix for invalid args', () => {
24
+ expect( () => validateGenerateTextArgs( {
25
+ variables: { topic: 'testing' }
26
+ } ) ).toThrow( ValidationError );
27
+
28
+ expect( () => validateGenerateTextArgs( {
29
+ prompt: 123
30
+ } ) ).toThrow( /Invalid generateText\(\) arguments/ );
31
+ } );
32
+
33
+ it( 'throws ValidationError for invalid promptDir, skills, or maxSteps', () => {
34
+ expect( () => validateGenerateTextArgs( {
35
+ prompt: 'summary@v1',
36
+ promptDir: ''
37
+ } ) ).toThrow( ValidationError );
38
+
39
+ expect( () => validateGenerateTextArgs( {
40
+ prompt: 'summary@v1',
41
+ skills: [ { name: '', description: 'Style', instructions: '# Style' } ]
42
+ } ) ).toThrow( ValidationError );
43
+
44
+ expect( () => validateGenerateTextArgs( {
45
+ prompt: 'summary@v1',
46
+ maxSteps: 0
47
+ } ) ).toThrow( ValidationError );
48
+ } );
49
+ } );
50
+
51
+ describe( 'validateStreamTextArgs', () => {
52
+ it( 'accepts a prompt with optional variables', () => {
53
+ expect( () => validateStreamTextArgs( {
54
+ prompt: 'summary@v1',
55
+ variables: [ 'arrays are accepted by the current schema' ],
56
+ promptDir: '/prompts',
57
+ skills: [ { name: 'style', description: 'Style', instructions: '# Style' } ],
58
+ maxSteps: 4
59
+ } ) ).not.toThrow();
60
+ } );
61
+
62
+ it( 'accepts a dynamic skills function', () => {
63
+ expect( () => validateStreamTextArgs( {
64
+ prompt: 'summary@v1',
65
+ skills: () => [ { name: 'style', description: 'Style', instructions: '# Style' } ]
66
+ } ) ).not.toThrow();
67
+ } );
68
+
69
+ it( 'throws ValidationError with streamText prefix for invalid args', () => {
70
+ expect( () => validateStreamTextArgs( {} ) ).toThrow( ValidationError );
71
+ expect( () => validateStreamTextArgs( { prompt: null } ) ).toThrow( /Invalid streamText\(\) arguments/ );
72
+ } );
73
+
74
+ it( 'throws ValidationError for invalid promptDir, skills, or maxSteps', () => {
75
+ expect( () => validateStreamTextArgs( {
76
+ prompt: 'summary@v1',
77
+ promptDir: ''
78
+ } ) ).toThrow( ValidationError );
79
+
80
+ expect( () => validateStreamTextArgs( {
81
+ prompt: 'summary@v1',
82
+ skills: [ { name: 'style', description: 'Style', instructions: '' } ]
83
+ } ) ).toThrow( ValidationError );
84
+
85
+ expect( () => validateStreamTextArgs( {
86
+ prompt: 'summary@v1',
87
+ maxSteps: 1.5
88
+ } ) ).toThrow( ValidationError );
89
+ } );
90
+ } );
91
+
92
+ describe( 'validateGenerateImageArgs', () => {
93
+ it( 'accepts text-to-image args without images or mask', () => {
94
+ expect( () => validateGenerateImageArgs( {
95
+ prompt: 'image@v1',
96
+ variables: { topic: 'race cars' },
97
+ promptDir: '/prompts'
98
+ } ) ).not.toThrow();
99
+ } );
100
+
101
+ it( 'accepts all supported image input shapes', () => {
102
+ const buffer = Buffer.from( 'image-bytes' );
103
+ const uint8Array = new Uint8Array( [ 1, 2, 3 ] );
104
+ const arrayBuffer = new ArrayBuffer( 3 );
105
+ const paddedBase64 = 'aW1hZ2UtYnl0ZXM=';
106
+ const unpaddedBase64 = 'aW1hZ2U';
107
+
108
+ expect( () => validateGenerateImageArgs( {
109
+ prompt: 'image@v1',
110
+ images: [
111
+ buffer,
112
+ uint8Array,
113
+ arrayBuffer,
114
+ paddedBase64,
115
+ unpaddedBase64,
116
+ { data: buffer, mediaType: 'image/png' },
117
+ { data: uint8Array },
118
+ { data: arrayBuffer, mediaType: 'image/jpeg' },
119
+ { data: paddedBase64, mediaType: 'image/webp' }
120
+ ],
121
+ mask: { data: Buffer.from( 'mask-bytes' ), mediaType: 'image/png' }
122
+ } ) ).not.toThrow();
123
+ } );
124
+
125
+ it( 'throws ValidationError for invalid image args', () => {
126
+ expect( () => validateGenerateImageArgs( {
127
+ prompt: ''
128
+ } ) ).toThrow( /Invalid generateImage\(\) arguments/ );
129
+
130
+ expect( () => validateGenerateImageArgs( {
131
+ prompt: 'image@v1',
132
+ images: []
133
+ } ) ).toThrow( ValidationError );
134
+
135
+ expect( () => validateGenerateImageArgs( {
136
+ prompt: 'image@v1',
137
+ images: [ { data: null } ]
138
+ } ) ).toThrow( ValidationError );
139
+
140
+ expect( () => validateGenerateImageArgs( {
141
+ prompt: 'image@v1',
142
+ images: [ { data: 'aW1hZ2U=', mediaType: '' } ]
143
+ } ) ).toThrow( ValidationError );
144
+ } );
145
+
146
+ it( 'rejects image strings that are not raw base64 data', () => {
147
+ for ( const image of [
148
+ 'https://example.com/image.png',
149
+ 'data:image/png;base64,aW1hZ2U=',
150
+ 'not base64',
151
+ 'abcde'
152
+ ] ) {
153
+ expect( () => validateGenerateImageArgs( {
154
+ prompt: 'image@v1',
155
+ images: [ image ]
156
+ } ) ).toThrow( /Image strings must be raw base64 data/ );
157
+ }
158
+ } );
159
+
160
+ it( 'requires images when mask is provided', () => {
161
+ expect( () => validateGenerateImageArgs( {
162
+ prompt: 'image@v1',
163
+ mask: Buffer.from( 'mask-bytes' )
164
+ } ) ).toThrow( /mask requires images/ );
165
+ } );
166
+ } );
package/src/parser.js DELETED
@@ -1,28 +0,0 @@
1
- import matter from 'gray-matter';
2
- import { FatalError } from '@outputai/core';
3
-
4
- export function parsePrompt( raw ) {
5
- const { data: config, content } = matter( raw );
6
-
7
- if ( !content || content.trim() === '' ) {
8
- throw new FatalError( 'Prompt file has no content after frontmatter' );
9
- }
10
-
11
- const infoExtractor = /<(system|user|assistant|tool)>([\s\S]*?)<\/\1>/gm;
12
- const messages = [ ...content.matchAll( infoExtractor ) ].map(
13
- ( [ _, role, text ] ) => ( { role, content: text.trim() } )
14
- );
15
-
16
- if ( messages.length === 0 ) {
17
- const contentPreview = content.substring( 0, 200 );
18
- const ellipsis = content.length > 200 ? '...' : '';
19
-
20
- throw new FatalError(
21
- `No valid message blocks found in prompt file.
22
- Expected format: <system>...</system>, <user>...</user>, etc.
23
- Content preview: ${contentPreview}${ellipsis}`
24
- );
25
- }
26
-
27
- return { config, messages };
28
- }