@outputai/llm 0.11.0 → 0.11.1-next.2223fa5.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 +105 -83
- package/src/agent.spec.js +312 -225
- package/src/ai_provider.js +2 -2
- package/src/ai_sdk_options.js +93 -39
- package/src/ai_sdk_options.spec.js +314 -154
- package/src/consts.js +6 -0
- package/src/generate.js +81 -0
- package/src/generate.spec.js +466 -0
- package/src/index.d.ts +222 -242
- package/src/index.js +3 -4
- package/src/prompt/content.js +70 -0
- package/src/prompt/content.spec.js +208 -0
- package/src/prompt/{escape.js → interpolations.js} +11 -18
- package/src/prompt/interpolations.spec.js +109 -0
- package/src/prompt/loader.full.spec.js +293 -0
- package/src/prompt/loader.js +45 -39
- package/src/prompt/loader.spec.js +186 -289
- package/src/prompt/markup/attributes.js +52 -0
- package/src/prompt/markup/attributes.spec.js +132 -0
- package/src/prompt/markup/nodes.js +71 -0
- package/src/prompt/markup/nodes.spec.js +333 -0
- package/src/prompt/markup/tokenizer.js +3 -0
- package/src/prompt/markup/tokenizer.spec.js +149 -0
- package/src/prompt/markup/tokens.js +26 -0
- package/src/prompt/markup/tokens.spec.js +250 -0
- package/src/prompt/validations.js +98 -67
- package/src/prompt/validations.spec.js +204 -47
- package/src/{prompt/load_content.js → utils/file.js} +12 -15
- package/src/utils/file.spec.js +89 -0
- package/src/utils/models.js +15 -0
- package/src/utils/models.spec.js +119 -0
- package/src/utils/skills.js +74 -0
- package/src/utils/skills.spec.js +168 -0
- package/src/utils/sources.js +48 -0
- package/src/utils/sources.spec.js +122 -0
- package/src/utils/stream.js +22 -0
- package/src/utils/stream.spec.js +55 -0
- package/src/utils/tools.js +47 -0
- package/src/utils/tools.spec.js +167 -0
- package/src/utils/wrap.js +148 -0
- package/src/utils/wrap.spec.js +359 -0
- package/src/validations.js +149 -27
- package/src/validations.spec.js +451 -53
- package/src/ai_model.js +0 -60
- package/src/ai_model.spec.js +0 -259
- package/src/ai_sdk.js +0 -92
- package/src/ai_sdk.spec.js +0 -564
- package/src/prompt/block_options.js +0 -58
- package/src/prompt/block_options.spec.js +0 -71
- package/src/prompt/blocks.js +0 -47
- package/src/prompt/blocks.spec.js +0 -63
- package/src/prompt/escape.spec.js +0 -159
- package/src/prompt/load_content.spec.js +0 -83
- package/src/prompt/loader_validation.spec.js +0 -128
- package/src/prompt/parser.js +0 -16
- package/src/prompt/parser.spec.js +0 -186
- package/src/prompt/prepare_text.js +0 -27
- package/src/prompt/prepare_text.spec.js +0 -141
- package/src/prompt/skill.js +0 -128
- package/src/prompt/skill.spec.js +0 -172
- package/src/utils/message.js +0 -3
- package/src/utils/message.spec.js +0 -29
- package/src/utils/response_wrappers.js +0 -100
- package/src/utils/response_wrappers.spec.js +0 -240
- package/src/utils/source_extraction.js +0 -53
- package/src/utils/source_extraction.spec.js +0 -194
- package/src/utils/trace.js +0 -19
- package/src/utils/trace.spec.js +0 -112
|
@@ -1,240 +0,0 @@
|
|
|
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' };
|
|
7
|
-
|
|
8
|
-
const mocks = vi.hoisted( () => ( {
|
|
9
|
-
combineSources: vi.fn(),
|
|
10
|
-
extractSourcesFromSteps: vi.fn(),
|
|
11
|
-
calculateLLMCallCost: vi.fn(),
|
|
12
|
-
endTraceWithSuccess: vi.fn(),
|
|
13
|
-
calculateBase64FileSize: vi.fn()
|
|
14
|
-
} ) );
|
|
15
|
-
|
|
16
|
-
vi.mock( './source_extraction.js', () => ( {
|
|
17
|
-
combineSources: mocks.combineSources,
|
|
18
|
-
extractSourcesFromSteps: mocks.extractSourcesFromSteps
|
|
19
|
-
} ) );
|
|
20
|
-
|
|
21
|
-
vi.mock( '../cost/index.js', () => ( {
|
|
22
|
-
calculateLLMCallCost: mocks.calculateLLMCallCost
|
|
23
|
-
} ) );
|
|
24
|
-
|
|
25
|
-
vi.mock( './trace.js', () => ( {
|
|
26
|
-
endTraceWithSuccess: mocks.endTraceWithSuccess
|
|
27
|
-
} ) );
|
|
28
|
-
|
|
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
|
-
} );
|
|
52
|
-
|
|
53
|
-
describe( 'wrapTextResponse', () => {
|
|
54
|
-
const traceId = 'trace-1';
|
|
55
|
-
const providerId = 'openai';
|
|
56
|
-
const modelId = 'test-model';
|
|
57
|
-
const mockCost = { total: 0.001, components: [ { name: 'input_tokens', value: 0.001 } ] };
|
|
58
|
-
|
|
59
|
-
beforeEach( () => {
|
|
60
|
-
vi.clearAllMocks();
|
|
61
|
-
mocks.extractSourcesFromSteps.mockReturnValue( [] );
|
|
62
|
-
mocks.calculateLLMCallCost.mockResolvedValue( mockCost );
|
|
63
|
-
mocks.combineSources.mockReturnValue( [] );
|
|
64
|
-
} );
|
|
65
|
-
|
|
66
|
-
it( 'uses a text response fixture to calculate cost, end trace, and attach cost', async () => {
|
|
67
|
-
const response = clone( textResponseFixture );
|
|
68
|
-
|
|
69
|
-
const wrapped = await wrapTextResponse( { traceId, providerId, modelId, response } );
|
|
70
|
-
|
|
71
|
-
expect( wrapped.result ).toBe( response.text );
|
|
72
|
-
expect( wrapped.cost ).toEqual( mockCost );
|
|
73
|
-
expect( mocks.calculateLLMCallCost ).toHaveBeenCalledWith( {
|
|
74
|
-
usage: response.totalUsage,
|
|
75
|
-
modelId,
|
|
76
|
-
providerId
|
|
77
|
-
} );
|
|
78
|
-
expect( mocks.extractSourcesFromSteps ).toHaveBeenCalledWith( response.steps );
|
|
79
|
-
expect( mocks.endTraceWithSuccess ).toHaveBeenCalledWith( {
|
|
80
|
-
traceId,
|
|
81
|
-
usage: response.totalUsage,
|
|
82
|
-
cost: mockCost,
|
|
83
|
-
result: response.text,
|
|
84
|
-
providerMetadata: response.providerMetadata,
|
|
85
|
-
sourcesFromTools: []
|
|
86
|
-
} );
|
|
87
|
-
} );
|
|
88
|
-
|
|
89
|
-
it( 'leaves sources unchanged when no tool-extracted sources are found', async () => {
|
|
90
|
-
const response = clone( streamResponseFixture );
|
|
91
|
-
const nativeSources = [
|
|
92
|
-
{ type: 'source', sourceType: 'url', id: 'n1', url: 'https://native.test', title: 'Native' }
|
|
93
|
-
];
|
|
94
|
-
response.sources = nativeSources;
|
|
95
|
-
mocks.extractSourcesFromSteps.mockReturnValue( [] );
|
|
96
|
-
|
|
97
|
-
const wrapped = await wrapTextResponse( { traceId, providerId, modelId, response } );
|
|
98
|
-
|
|
99
|
-
expect( wrapped.sources ).toBe( nativeSources );
|
|
100
|
-
expect( mocks.combineSources ).not.toHaveBeenCalled();
|
|
101
|
-
} );
|
|
102
|
-
|
|
103
|
-
it( 'delegates source merging when tool-extracted sources exist', async () => {
|
|
104
|
-
const response = clone( streamResponseFixture );
|
|
105
|
-
const toolSource = {
|
|
106
|
-
type: 'source',
|
|
107
|
-
sourceType: 'url',
|
|
108
|
-
id: 'id-a',
|
|
109
|
-
url: 'https://example.com/a',
|
|
110
|
-
title: 'A'
|
|
111
|
-
};
|
|
112
|
-
const responseSource = {
|
|
113
|
-
type: 'source',
|
|
114
|
-
sourceType: 'url',
|
|
115
|
-
id: 'id-b',
|
|
116
|
-
url: 'https://example.com/b',
|
|
117
|
-
title: 'B'
|
|
118
|
-
};
|
|
119
|
-
const mergedSources = [ toolSource, responseSource ];
|
|
120
|
-
response.sources = [ responseSource ];
|
|
121
|
-
mocks.extractSourcesFromSteps.mockReturnValue( [ toolSource ] );
|
|
122
|
-
mocks.combineSources.mockReturnValue( mergedSources );
|
|
123
|
-
|
|
124
|
-
const wrapped = await wrapTextResponse( { traceId, providerId, modelId, response } );
|
|
125
|
-
|
|
126
|
-
expect( wrapped.sources ).toBe( mergedSources );
|
|
127
|
-
expect( mocks.combineSources ).toHaveBeenCalledWith( {
|
|
128
|
-
sourcesFromTools: [ toolSource ],
|
|
129
|
-
sourcesFromResponse: [ responseSource ]
|
|
130
|
-
} );
|
|
131
|
-
} );
|
|
132
|
-
} );
|
|
133
|
-
|
|
134
|
-
describe( 'wrapStreamOnFinishResponse', () => {
|
|
135
|
-
const traceId = 'stream-trace';
|
|
136
|
-
const providerId = 'openai';
|
|
137
|
-
const modelId = 'stream-model';
|
|
138
|
-
const mockCost = { total: 0.002, components: [] };
|
|
139
|
-
|
|
140
|
-
beforeEach( () => {
|
|
141
|
-
vi.clearAllMocks();
|
|
142
|
-
mocks.calculateLLMCallCost.mockResolvedValue( mockCost );
|
|
143
|
-
mocks.extractSourcesFromSteps.mockReturnValue( [] );
|
|
144
|
-
} );
|
|
145
|
-
|
|
146
|
-
it( 'uses the stream response fixture to finish trace and call the user callback with a proxied response', async () => {
|
|
147
|
-
const userOnFinish = vi.fn();
|
|
148
|
-
const response = clone( streamResponseFixture );
|
|
149
|
-
|
|
150
|
-
const callbacks = wrapStreamOnFinishResponse( {
|
|
151
|
-
traceId,
|
|
152
|
-
providerId,
|
|
153
|
-
modelId,
|
|
154
|
-
onFinish: userOnFinish
|
|
155
|
-
} );
|
|
156
|
-
|
|
157
|
-
await callbacks.onFinish( response );
|
|
158
|
-
|
|
159
|
-
expect( mocks.endTraceWithSuccess ).toHaveBeenCalledWith( {
|
|
160
|
-
traceId,
|
|
161
|
-
usage: response.totalUsage,
|
|
162
|
-
cost: mockCost,
|
|
163
|
-
result: response.text,
|
|
164
|
-
providerMetadata: response.providerMetadata,
|
|
165
|
-
sourcesFromTools: []
|
|
166
|
-
} );
|
|
167
|
-
expect( userOnFinish ).toHaveBeenCalledTimes( 1 );
|
|
168
|
-
const proxied = userOnFinish.mock.calls[0][0];
|
|
169
|
-
expect( proxied.result ).toBe( response.text );
|
|
170
|
-
expect( proxied.cost ).toEqual( mockCost );
|
|
171
|
-
expect( proxied.finishReason ).toBe( response.finishReason );
|
|
172
|
-
expect( mocks.extractSourcesFromSteps ).toHaveBeenCalledWith( response.steps );
|
|
173
|
-
} );
|
|
174
|
-
|
|
175
|
-
it( 'finishes trace even when no user onFinish callback is provided', async () => {
|
|
176
|
-
const response = clone( streamResponseFixture );
|
|
177
|
-
|
|
178
|
-
const callbacks = wrapStreamOnFinishResponse( {
|
|
179
|
-
traceId,
|
|
180
|
-
providerId,
|
|
181
|
-
modelId
|
|
182
|
-
} );
|
|
183
|
-
|
|
184
|
-
await callbacks.onFinish( response );
|
|
185
|
-
|
|
186
|
-
expect( mocks.endTraceWithSuccess ).toHaveBeenCalledWith( {
|
|
187
|
-
traceId,
|
|
188
|
-
usage: response.totalUsage,
|
|
189
|
-
cost: mockCost,
|
|
190
|
-
result: response.text,
|
|
191
|
-
providerMetadata: response.providerMetadata,
|
|
192
|
-
sourcesFromTools: []
|
|
193
|
-
} );
|
|
194
|
-
expect( mocks.calculateLLMCallCost ).toHaveBeenCalledWith( {
|
|
195
|
-
usage: response.totalUsage,
|
|
196
|
-
modelId,
|
|
197
|
-
providerId
|
|
198
|
-
} );
|
|
199
|
-
} );
|
|
200
|
-
} );
|
|
201
|
-
|
|
202
|
-
describe( 'wrapImageResponse', () => {
|
|
203
|
-
const traceId = 'image-trace';
|
|
204
|
-
const providerId = 'openai';
|
|
205
|
-
const modelId = 'image-model';
|
|
206
|
-
const mockCost = { total: 0.003, components: [] };
|
|
207
|
-
|
|
208
|
-
beforeEach( () => {
|
|
209
|
-
vi.clearAllMocks();
|
|
210
|
-
mocks.calculateLLMCallCost.mockResolvedValue( mockCost );
|
|
211
|
-
mocks.calculateBase64FileSize.mockReturnValue( 1234 );
|
|
212
|
-
} );
|
|
213
|
-
|
|
214
|
-
it( 'uses an image response fixture to trace image metadata and attach cost', async () => {
|
|
215
|
-
const response = await makeAiSdkImageResponse();
|
|
216
|
-
|
|
217
|
-
const wrapped = await wrapImageResponse( { traceId, providerId, modelId, response } );
|
|
218
|
-
|
|
219
|
-
expect( wrapped.result ).toBe( response.images[0] );
|
|
220
|
-
expect( wrapped.cost ).toEqual( mockCost );
|
|
221
|
-
expect( mocks.calculateLLMCallCost ).toHaveBeenCalledWith( {
|
|
222
|
-
usage: response.usage,
|
|
223
|
-
modelId,
|
|
224
|
-
providerId
|
|
225
|
-
} );
|
|
226
|
-
expect( mocks.calculateBase64FileSize ).toHaveBeenCalledWith( response.images[0].base64 );
|
|
227
|
-
expect( mocks.endTraceWithSuccess ).toHaveBeenCalledWith( {
|
|
228
|
-
traceId,
|
|
229
|
-
usage: response.usage,
|
|
230
|
-
cost: mockCost,
|
|
231
|
-
result: [
|
|
232
|
-
{
|
|
233
|
-
size: 1234,
|
|
234
|
-
mediaType: response.images[0].mediaType
|
|
235
|
-
}
|
|
236
|
-
],
|
|
237
|
-
providerMetadata: response.providerMetadata
|
|
238
|
-
} );
|
|
239
|
-
} );
|
|
240
|
-
} );
|
|
@@ -1,53 +0,0 @@
|
|
|
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 = v => Array.isArray( v?.results ) && v.results.some( r => typeof r?.url === 'string' );
|
|
7
|
-
|
|
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 } ) => ( {
|
|
16
|
-
type: 'source',
|
|
17
|
-
sourceType: 'url',
|
|
18
|
-
id: createHash( 'sha256' ).update( url ).digest( 'hex' ).slice( 0, 16 ),
|
|
19
|
-
url,
|
|
20
|
-
title: title ?? ''
|
|
21
|
-
} );
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Extracts source URLs from search tool results embedded in AI SDK step data.
|
|
25
|
-
*
|
|
26
|
-
* Detects any tool result containing a `results[]` array whose items have a `url` string field.
|
|
27
|
-
* This covers perplexitySearch, tavilySearch, exaSearch, and any future tool with the same shape.
|
|
28
|
-
*
|
|
29
|
-
* @param {Array} steps - AI SDK response steps (response.steps)
|
|
30
|
-
* @returns {Array<{ type: string, sourceType: string, id: string, url: string, title: string }>}
|
|
31
|
-
*/
|
|
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 ) );
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Merge sources used tools and sources from AI SDK response into a single list
|
|
43
|
-
*
|
|
44
|
-
* Deduplicate by url (prefer to keep items from sources from response).
|
|
45
|
-
*
|
|
46
|
-
* @param {object} args
|
|
47
|
-
* @param {object[]} args.sourcesFromTools
|
|
48
|
-
* @param {object[]} args.sourcesFromResponse
|
|
49
|
-
* @returns {object[]} Merged sources
|
|
50
|
-
*/
|
|
51
|
-
export const combineSources = ( { sourcesFromTools, sourcesFromResponse } ) =>
|
|
52
|
-
new Map( sourcesFromTools.concat( Array.isArray( sourcesFromResponse ) ? sourcesFromResponse : [] )
|
|
53
|
-
.map( s => [ s.url, s ] ) ).values().toArray();
|
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { combineSources, 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
|
-
} );
|
|
170
|
-
|
|
171
|
-
describe( 'combineSources', () => {
|
|
172
|
-
it( 'treats non-array sourcesFromResponse like empty array', () => {
|
|
173
|
-
const tool = [ { url: 'https://u.test', title: 'T', type: 'source', sourceType: 'url', id: '1' } ];
|
|
174
|
-
|
|
175
|
-
expect(
|
|
176
|
-
combineSources( { sourcesFromTools: tool, sourcesFromResponse: undefined } )
|
|
177
|
-
).toHaveLength( 1 );
|
|
178
|
-
|
|
179
|
-
expect(
|
|
180
|
-
combineSources( { sourcesFromTools: tool, sourcesFromResponse: {} } )
|
|
181
|
-
).toHaveLength( 1 );
|
|
182
|
-
} );
|
|
183
|
-
|
|
184
|
-
it( 'dedupes by url with response sources winning after tools', () => {
|
|
185
|
-
const url = 'https://shared.test';
|
|
186
|
-
const tools = [ { url, title: 'from-tool', type: 'source', sourceType: 'url', id: 'a' } ];
|
|
187
|
-
const response = [ { url, title: 'from-response', type: 'source', sourceType: 'url', id: 'b' } ];
|
|
188
|
-
|
|
189
|
-
const merged = combineSources( { sourcesFromTools: tools, sourcesFromResponse: response } );
|
|
190
|
-
|
|
191
|
-
expect( merged ).toHaveLength( 1 );
|
|
192
|
-
expect( merged[0].title ).toBe( 'from-response' );
|
|
193
|
-
} );
|
|
194
|
-
} );
|
package/src/utils/trace.js
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { Tracing, Event } from '@outputai/core/sdk/runtime';
|
|
2
|
-
|
|
3
|
-
export const startTrace = ( { name, ...details } ) => {
|
|
4
|
-
const traceId = `${name}-${Date.now()}`;
|
|
5
|
-
Tracing.addEventStart( { kind: 'llm', name, id: traceId, details } );
|
|
6
|
-
return traceId;
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
export const endTraceWithError = ( { traceId, error } ) => {
|
|
10
|
-
Tracing.addEventError( { id: traceId, details: error } );
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export const endTraceWithSuccess = ( { traceId, result, cost, ...extra } ) => {
|
|
14
|
-
if ( cost ) {
|
|
15
|
-
Tracing.addEventAttribute( { eventId: traceId, attribute: cost } );
|
|
16
|
-
Event.emit( 'cost:llm:request', cost );
|
|
17
|
-
}
|
|
18
|
-
Tracing.addEventEnd( { id: traceId, details: { result, ...extra } } );
|
|
19
|
-
};
|
package/src/utils/trace.spec.js
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
|
|
3
|
-
vi.mock( '@outputai/core/sdk/runtime', () => ( {
|
|
4
|
-
Tracing: {
|
|
5
|
-
addEventStart: vi.fn(),
|
|
6
|
-
addEventError: vi.fn(),
|
|
7
|
-
addEventAttribute: vi.fn(),
|
|
8
|
-
addEventEnd: vi.fn()
|
|
9
|
-
},
|
|
10
|
-
Event: {
|
|
11
|
-
emit: vi.fn()
|
|
12
|
-
}
|
|
13
|
-
} ) );
|
|
14
|
-
|
|
15
|
-
import { Tracing, Event } from '@outputai/core/sdk/runtime';
|
|
16
|
-
import { startTrace, endTraceWithError, endTraceWithSuccess } from './trace.js';
|
|
17
|
-
|
|
18
|
-
const tracing = vi.mocked( Tracing, true );
|
|
19
|
-
const event = vi.mocked( Event, true );
|
|
20
|
-
|
|
21
|
-
describe( 'trace utils', () => {
|
|
22
|
-
beforeEach( () => {
|
|
23
|
-
vi.clearAllMocks();
|
|
24
|
-
} );
|
|
25
|
-
|
|
26
|
-
afterEach( () => {
|
|
27
|
-
vi.restoreAllMocks();
|
|
28
|
-
} );
|
|
29
|
-
|
|
30
|
-
describe( 'startTrace', () => {
|
|
31
|
-
it( 'starts an llm trace with name-based id and passes remaining fields as details', () => {
|
|
32
|
-
vi.spyOn( Date, 'now' ).mockReturnValue( 9_000_000_000 );
|
|
33
|
-
|
|
34
|
-
const traceId = startTrace( { name: 'generateText', prompt: 'p', variables: { k: 1 } } );
|
|
35
|
-
|
|
36
|
-
expect( traceId ).toBe( 'generateText-9000000000' );
|
|
37
|
-
expect( tracing.addEventStart ).toHaveBeenCalledWith( {
|
|
38
|
-
kind: 'llm',
|
|
39
|
-
name: 'generateText',
|
|
40
|
-
id: 'generateText-9000000000',
|
|
41
|
-
details: { prompt: 'p', variables: { k: 1 } }
|
|
42
|
-
} );
|
|
43
|
-
} );
|
|
44
|
-
} );
|
|
45
|
-
|
|
46
|
-
describe( 'endTraceWithError', () => {
|
|
47
|
-
it( 'records an error on the trace event', () => {
|
|
48
|
-
const err = new Error( 'failed' );
|
|
49
|
-
|
|
50
|
-
endTraceWithError( { traceId: 't-1', error: err } );
|
|
51
|
-
|
|
52
|
-
expect( tracing.addEventError ).toHaveBeenCalledWith( { id: 't-1', details: err } );
|
|
53
|
-
} );
|
|
54
|
-
} );
|
|
55
|
-
|
|
56
|
-
describe( 'endTraceWithSuccess', () => {
|
|
57
|
-
it( 'adds cost attribute, emits cost event, and ends the trace with normalized details', () => {
|
|
58
|
-
const cost = { type: 'llm:usage', modelId: 'my-model', total: 0.01, usage: [] };
|
|
59
|
-
const usage = { inputTokens: 2, outputTokens: 3 };
|
|
60
|
-
|
|
61
|
-
endTraceWithSuccess( {
|
|
62
|
-
traceId: 'trace-a',
|
|
63
|
-
modelId: 'my-model',
|
|
64
|
-
result: 'hello',
|
|
65
|
-
usage,
|
|
66
|
-
providerMetadata: { provider: 'x' },
|
|
67
|
-
cost,
|
|
68
|
-
sourcesFromTools: [ { url: 'https://u.test', title: '' } ]
|
|
69
|
-
} );
|
|
70
|
-
|
|
71
|
-
expect( tracing.addEventAttribute ).toHaveBeenCalledWith( {
|
|
72
|
-
eventId: 'trace-a',
|
|
73
|
-
attribute: cost
|
|
74
|
-
} );
|
|
75
|
-
expect( event.emit ).toHaveBeenCalledWith( 'cost:llm:request', cost );
|
|
76
|
-
expect( tracing.addEventEnd ).toHaveBeenCalledWith( {
|
|
77
|
-
id: 'trace-a',
|
|
78
|
-
details: {
|
|
79
|
-
result: 'hello',
|
|
80
|
-
modelId: 'my-model',
|
|
81
|
-
usage,
|
|
82
|
-
providerMetadata: { provider: 'x' },
|
|
83
|
-
sourcesFromTools: [ { url: 'https://u.test', title: '' } ]
|
|
84
|
-
}
|
|
85
|
-
} );
|
|
86
|
-
} );
|
|
87
|
-
|
|
88
|
-
it( 'does not emit or add an attribute when cost is missing', () => {
|
|
89
|
-
const usage = { inputTokens: 2, outputTokens: 3 };
|
|
90
|
-
|
|
91
|
-
endTraceWithSuccess( {
|
|
92
|
-
traceId: 'trace-no-cost',
|
|
93
|
-
modelId: 'my-model',
|
|
94
|
-
result: 'hello',
|
|
95
|
-
usage,
|
|
96
|
-
providerMetadata: { provider: 'x' }
|
|
97
|
-
} );
|
|
98
|
-
|
|
99
|
-
expect( tracing.addEventAttribute ).not.toHaveBeenCalled();
|
|
100
|
-
expect( event.emit ).not.toHaveBeenCalled();
|
|
101
|
-
expect( tracing.addEventEnd ).toHaveBeenCalledWith( {
|
|
102
|
-
id: 'trace-no-cost',
|
|
103
|
-
details: {
|
|
104
|
-
result: 'hello',
|
|
105
|
-
modelId: 'my-model',
|
|
106
|
-
usage,
|
|
107
|
-
providerMetadata: { provider: 'x' }
|
|
108
|
-
}
|
|
109
|
-
} );
|
|
110
|
-
} );
|
|
111
|
-
} );
|
|
112
|
-
} );
|