@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.
Files changed (69) hide show
  1. package/package.json +2 -2
  2. package/src/agent.js +105 -83
  3. package/src/agent.spec.js +312 -225
  4. package/src/ai_provider.js +2 -2
  5. package/src/ai_sdk_options.js +93 -39
  6. package/src/ai_sdk_options.spec.js +314 -154
  7. package/src/consts.js +6 -0
  8. package/src/generate.js +81 -0
  9. package/src/generate.spec.js +466 -0
  10. package/src/index.d.ts +222 -242
  11. package/src/index.js +3 -4
  12. package/src/prompt/content.js +70 -0
  13. package/src/prompt/content.spec.js +208 -0
  14. package/src/prompt/{escape.js → interpolations.js} +11 -18
  15. package/src/prompt/interpolations.spec.js +109 -0
  16. package/src/prompt/loader.full.spec.js +293 -0
  17. package/src/prompt/loader.js +45 -39
  18. package/src/prompt/loader.spec.js +186 -289
  19. package/src/prompt/markup/attributes.js +52 -0
  20. package/src/prompt/markup/attributes.spec.js +132 -0
  21. package/src/prompt/markup/nodes.js +71 -0
  22. package/src/prompt/markup/nodes.spec.js +333 -0
  23. package/src/prompt/markup/tokenizer.js +3 -0
  24. package/src/prompt/markup/tokenizer.spec.js +149 -0
  25. package/src/prompt/markup/tokens.js +26 -0
  26. package/src/prompt/markup/tokens.spec.js +250 -0
  27. package/src/prompt/validations.js +98 -67
  28. package/src/prompt/validations.spec.js +204 -47
  29. package/src/{prompt/load_content.js → utils/file.js} +12 -15
  30. package/src/utils/file.spec.js +89 -0
  31. package/src/utils/models.js +15 -0
  32. package/src/utils/models.spec.js +119 -0
  33. package/src/utils/skills.js +74 -0
  34. package/src/utils/skills.spec.js +168 -0
  35. package/src/utils/sources.js +48 -0
  36. package/src/utils/sources.spec.js +122 -0
  37. package/src/utils/stream.js +22 -0
  38. package/src/utils/stream.spec.js +55 -0
  39. package/src/utils/tools.js +47 -0
  40. package/src/utils/tools.spec.js +167 -0
  41. package/src/utils/wrap.js +148 -0
  42. package/src/utils/wrap.spec.js +359 -0
  43. package/src/validations.js +149 -27
  44. package/src/validations.spec.js +451 -53
  45. package/src/ai_model.js +0 -60
  46. package/src/ai_model.spec.js +0 -259
  47. package/src/ai_sdk.js +0 -92
  48. package/src/ai_sdk.spec.js +0 -564
  49. package/src/prompt/block_options.js +0 -58
  50. package/src/prompt/block_options.spec.js +0 -71
  51. package/src/prompt/blocks.js +0 -47
  52. package/src/prompt/blocks.spec.js +0 -63
  53. package/src/prompt/escape.spec.js +0 -159
  54. package/src/prompt/load_content.spec.js +0 -83
  55. package/src/prompt/loader_validation.spec.js +0 -128
  56. package/src/prompt/parser.js +0 -16
  57. package/src/prompt/parser.spec.js +0 -186
  58. package/src/prompt/prepare_text.js +0 -27
  59. package/src/prompt/prepare_text.spec.js +0 -141
  60. package/src/prompt/skill.js +0 -128
  61. package/src/prompt/skill.spec.js +0 -172
  62. package/src/utils/message.js +0 -3
  63. package/src/utils/message.spec.js +0 -29
  64. package/src/utils/response_wrappers.js +0 -100
  65. package/src/utils/response_wrappers.spec.js +0 -240
  66. package/src/utils/source_extraction.js +0 -53
  67. package/src/utils/source_extraction.spec.js +0 -194
  68. package/src/utils/trace.js +0 -19
  69. 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
- } );
@@ -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
- };
@@ -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
- } );