@outputai/llm 0.6.1-dev.aab2335.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.
- package/package.json +2 -2
- package/src/agent.js +15 -9
- package/src/agent.spec.js +295 -214
- package/src/ai_model.js +79 -36
- package/src/ai_model.spec.js +31 -13
- package/src/ai_sdk.js +55 -79
- package/src/ai_sdk.spec.js +464 -611
- package/src/ai_sdk_options.js +61 -0
- package/src/ai_sdk_options.spec.js +164 -0
- package/src/cost/index.js +1 -1
- package/src/index.d.ts +230 -175
- package/src/index.js +2 -2
- package/src/prompt/escape.js +65 -0
- package/src/prompt/escape.spec.js +159 -0
- package/src/{load_content.js → prompt/load_content.js} +1 -22
- package/src/{load_content.spec.js → prompt/load_content.spec.js} +6 -6
- package/src/prompt/loader.js +49 -0
- package/src/prompt/loader.spec.js +274 -0
- package/src/{prompt_loader_validation.spec.js → prompt/loader_validation.spec.js} +40 -7
- package/src/prompt/parser.js +19 -0
- package/src/{parser.spec.js → prompt/parser.spec.js} +74 -29
- package/src/prompt/prepare_text.js +27 -0
- package/src/prompt/prepare_text.spec.js +141 -0
- package/src/{skill.js → prompt/skill.js} +19 -0
- package/src/prompt/skill.spec.js +172 -0
- package/src/{prompt_validations.js → prompt/validations.js} +32 -6
- package/src/{prompt_validations.spec.js → prompt/validations.spec.js} +189 -1
- package/src/utils/__fixtures__/image_response.json +38 -0
- package/src/utils/__fixtures__/stream_response.json +294 -0
- package/src/utils/__fixtures__/text_response.json +201 -0
- package/src/utils/error_handler.js +65 -0
- package/src/utils/error_handler.spec.js +195 -0
- package/src/utils/image.js +10 -0
- package/src/utils/image.spec.js +20 -0
- package/src/utils/response_wrappers.js +46 -19
- package/src/utils/response_wrappers.spec.js +130 -70
- package/src/utils/source_extraction.js +17 -27
- package/src/utils/trace.js +2 -3
- package/src/utils/trace.spec.js +9 -13
- package/src/validations.js +54 -2
- package/src/validations.spec.js +166 -0
- package/src/parser.js +0 -28
- package/src/prompt_loader.js +0 -80
- package/src/prompt_loader.spec.js +0 -358
- 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',
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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( '
|
|
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(
|
|
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
|
-
|
|
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
|
|
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( '
|
|
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 ).
|
|
130
|
-
expect(
|
|
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( '
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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(
|
|
165
|
+
expect( proxied.result ).toBe( response.text );
|
|
169
166
|
expect( proxied.cost ).toEqual( mockCost );
|
|
170
|
-
expect( proxied.finishReason ).toBe(
|
|
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 =
|
|
6
|
+
const isSearchResult = v => Array.isArray( v?.results ) && v.results.some( r => typeof r?.url === 'string' );
|
|
7
7
|
|
|
8
|
-
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
package/src/utils/trace.js
CHANGED
|
@@ -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,
|
|
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,
|
|
18
|
+
Tracing.addEventEnd( { id: traceId, details: { result, ...extra } } );
|
|
20
19
|
};
|
package/src/utils/trace.spec.js
CHANGED
|
@@ -51,19 +51,16 @@ describe( 'trace utils', () => {
|
|
|
51
51
|
} );
|
|
52
52
|
|
|
53
53
|
describe( 'endTraceWithSuccess', () => {
|
|
54
|
-
it( 'adds cost attribute, emits cost
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/validations.js
CHANGED
|
@@ -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
|
-
}
|