@outputai/llm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,633 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ const tracingSpies = {
4
+ addEventStart: vi.fn(),
5
+ addEventEnd: vi.fn(),
6
+ addEventError: vi.fn()
7
+ };
8
+ const emitEventSpy = vi.fn();
9
+ vi.mock( '@outputai/core/sdk_activity_integration', () => ( {
10
+ Tracing: tracingSpies,
11
+ emitEvent: emitEventSpy
12
+ } ), { virtual: true } );
13
+
14
+ const loadModelImpl = vi.fn();
15
+ const loadToolsImpl = vi.fn();
16
+ vi.mock( './ai_model.js', () => ( {
17
+ loadModel: ( ...values ) => loadModelImpl( ...values ),
18
+ loadTools: ( ...values ) => loadToolsImpl( ...values )
19
+ } ) );
20
+
21
+ const aiFns = {
22
+ generateText: vi.fn(),
23
+ streamText: vi.fn()
24
+ };
25
+ vi.mock( 'ai', () => ( aiFns ) );
26
+
27
+ const validators = {
28
+ validateGenerateTextArgs: vi.fn(),
29
+ validateStreamTextArgs: vi.fn()
30
+ };
31
+ vi.mock( './validations.js', () => ( validators ) );
32
+
33
+ const loadPromptImpl = vi.fn();
34
+ vi.mock( './prompt_loader.js', () => ( {
35
+ loadPrompt: ( ...values ) => loadPromptImpl( ...values )
36
+ } ) );
37
+
38
+ const extractSourcesFromStepsImpl = vi.fn().mockReturnValue( [] );
39
+ vi.mock( './source_extraction.js', () => ( {
40
+ extractSourcesFromSteps: ( ...args ) => extractSourcesFromStepsImpl( ...args )
41
+ } ) );
42
+
43
+ const calculateLLMCallCostImpl = vi.fn();
44
+ vi.mock( './cost/index.js', () => ( {
45
+ calculateLLMCallCost: ( ...args ) => calculateLLMCallCostImpl( ...args )
46
+ } ) );
47
+
48
+ const importSut = async () => import( './ai_sdk.js' );
49
+
50
+ const basePrompt = {
51
+ config: {
52
+ provider: 'openai',
53
+ model: 'gpt-4o-mini',
54
+ temperature: 0.3,
55
+ providerOptions: { thinking: { enabled: true } }
56
+ },
57
+ messages: [ { role: 'user', content: 'Hi' } ]
58
+ };
59
+
60
+ const cost = 'calculate cost';
61
+
62
+ beforeEach( () => {
63
+ emitEventSpy.mockReset();
64
+ loadModelImpl.mockReset().mockReturnValue( 'MODEL' );
65
+ loadPromptImpl.mockReset().mockReturnValue( basePrompt );
66
+ extractSourcesFromStepsImpl.mockReset().mockReturnValue( [] );
67
+ calculateLLMCallCostImpl.mockReset().mockResolvedValue( cost );
68
+
69
+ const defaultUsage = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
70
+ aiFns.generateText.mockReset().mockResolvedValue( {
71
+ text: 'TEXT',
72
+ sources: [],
73
+ usage: defaultUsage,
74
+ totalUsage: defaultUsage,
75
+ finishReason: 'stop'
76
+ } );
77
+
78
+ aiFns.streamText.mockReset().mockReturnValue( {
79
+ textStream: 'MOCK_TEXT_STREAM',
80
+ fullStream: 'MOCK_FULL_STREAM',
81
+ text: Promise.resolve( 'STREAMED_TEXT' ),
82
+ usage: Promise.resolve( { inputTokens: 10, outputTokens: 5, totalTokens: 15 } ),
83
+ finishReason: Promise.resolve( 'stop' ),
84
+ sources: Promise.resolve( [] )
85
+ } );
86
+ } );
87
+
88
+ afterEach( async () => {
89
+ await vi.resetModules();
90
+ vi.clearAllMocks();
91
+ } );
92
+
93
+ describe( 'ai_sdk', () => {
94
+ it( 'generateText: validates, traces, calls AI and returns text', async () => {
95
+ const { generateText } = await importSut();
96
+ const result = await generateText( { prompt: 'test_prompt@v1' } );
97
+
98
+ expect( validators.validateGenerateTextArgs ).toHaveBeenCalledWith( { prompt: 'test_prompt@v1' } );
99
+ expect( loadPromptImpl ).toHaveBeenCalledWith( 'test_prompt@v1', undefined );
100
+ expect( tracingSpies.addEventStart ).toHaveBeenCalledTimes( 1 );
101
+ expect( tracingSpies.addEventEnd ).toHaveBeenCalledTimes( 1 );
102
+ expect( tracingSpies.addEventEnd ).toHaveBeenCalledWith(
103
+ expect.objectContaining( { details: expect.objectContaining( { cost } ) } )
104
+ );
105
+ expect( calculateLLMCallCostImpl ).toHaveBeenCalledWith( {
106
+ usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
107
+ modelId: basePrompt.config.model
108
+ } );
109
+ const defaultUsage = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
110
+ expect( emitEventSpy ).toHaveBeenCalledTimes( 1 );
111
+ expect( emitEventSpy ).toHaveBeenCalledWith( 'llm:call_cost', {
112
+ modelId: basePrompt.config.model,
113
+ cost,
114
+ usage: defaultUsage
115
+ } );
116
+
117
+ expect( loadModelImpl ).toHaveBeenCalledWith( basePrompt );
118
+ expect( aiFns.generateText ).toHaveBeenCalledWith( {
119
+ model: 'MODEL',
120
+ messages: basePrompt.messages,
121
+ temperature: 0.3,
122
+ providerOptions: basePrompt.config.providerOptions
123
+ } );
124
+ expect( result.text ).toBe( 'TEXT' );
125
+ expect( result.sources ).toEqual( [] );
126
+ expect( result.usage ).toEqual( { inputTokens: 10, outputTokens: 5, totalTokens: 15 } );
127
+ expect( result.finishReason ).toBe( 'stop' );
128
+ } );
129
+
130
+ it( 'generateText: passes provider-specific options to AI SDK', async () => {
131
+ const promptWithProviderOptions = {
132
+ config: {
133
+ provider: 'anthropic',
134
+ model: 'claude-sonnet-4-20250514',
135
+ providerOptions: {
136
+ thinking: {
137
+ type: 'enabled',
138
+ budgetTokens: 5000
139
+ },
140
+ anthropic: {
141
+ effort: 'medium',
142
+ customOption: 'value'
143
+ },
144
+ customField: 'should-be-passed'
145
+ }
146
+ },
147
+ messages: [ { role: 'user', content: 'Test' } ]
148
+ };
149
+ loadPromptImpl.mockReturnValueOnce( promptWithProviderOptions );
150
+
151
+ const { generateText } = await importSut();
152
+ await generateText( { prompt: 'test_prompt@v1' } );
153
+
154
+ expect( aiFns.generateText ).toHaveBeenCalledWith( {
155
+ model: 'MODEL',
156
+ messages: promptWithProviderOptions.messages,
157
+ providerOptions: {
158
+ thinking: {
159
+ type: 'enabled',
160
+ budgetTokens: 5000
161
+ },
162
+ anthropic: {
163
+ effort: 'medium',
164
+ customOption: 'value'
165
+ },
166
+ customField: 'should-be-passed'
167
+ }
168
+ } );
169
+ } );
170
+
171
+ it( 'generateText: passes through providerMetadata', async () => {
172
+ const usageProvider = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
173
+ aiFns.generateText.mockResolvedValueOnce( {
174
+ text: 'TEXT',
175
+ sources: [],
176
+ usage: usageProvider,
177
+ totalUsage: usageProvider,
178
+ finishReason: 'stop',
179
+ providerMetadata: { anthropic: { cacheReadInputTokens: 50 } }
180
+ } );
181
+
182
+ const { generateText } = await importSut();
183
+ const result = await generateText( { prompt: 'test_prompt@v1' } );
184
+
185
+ expect( result.providerMetadata ).toEqual( { anthropic: { cacheReadInputTokens: 50 } } );
186
+ } );
187
+
188
+ it( 'generateText: passes through warnings and response metadata', async () => {
189
+ const usageWarnings = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
190
+ aiFns.generateText.mockResolvedValueOnce( {
191
+ text: 'TEXT',
192
+ sources: [],
193
+ usage: usageWarnings,
194
+ totalUsage: usageWarnings,
195
+ finishReason: 'stop',
196
+ warnings: [ { type: 'other', message: 'Test warning' } ],
197
+ response: { id: 'req_123', modelId: 'gpt-4o-2024-05-13' }
198
+ } );
199
+
200
+ const { generateText } = await importSut();
201
+ const result = await generateText( { prompt: 'test_prompt@v1' } );
202
+
203
+ expect( result.warnings ).toEqual( [ { type: 'other', message: 'Test warning' } ] );
204
+ expect( result.response ).toEqual( { id: 'req_123', modelId: 'gpt-4o-2024-05-13' } );
205
+ } );
206
+
207
+ it( 'generateText: includes unified result field that matches text', async () => {
208
+ const { generateText } = await importSut();
209
+ const response = await generateText( { prompt: 'test_prompt@v1' } );
210
+
211
+ expect( response.result ).toBe( 'TEXT' );
212
+ expect( response.result ).toBe( response.text );
213
+ } );
214
+
215
+ it( 'generateText: traces error and rethrows when AI SDK fails', async () => {
216
+ const error = new Error( 'API rate limit exceeded' );
217
+ aiFns.generateText.mockRejectedValueOnce( error );
218
+ const { generateText } = await importSut();
219
+
220
+ await expect( generateText( { prompt: 'test_prompt@v1' } ) ).rejects.toThrow( 'API rate limit exceeded' );
221
+ expect( tracingSpies.addEventError ).toHaveBeenCalledWith(
222
+ expect.objectContaining( { details: error } )
223
+ );
224
+ } );
225
+
226
+ it( 'generateText: Proxy correctly handles AI SDK response with getter', async () => {
227
+ const responseWithGetter = {
228
+ _internalText: 'TEXT_FROM_GETTER',
229
+ get text() {
230
+ return this._internalText;
231
+ },
232
+ sources: [],
233
+ usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
234
+ totalUsage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
235
+ finishReason: 'stop'
236
+ };
237
+ aiFns.generateText.mockResolvedValueOnce( responseWithGetter );
238
+
239
+ const { generateText } = await importSut();
240
+ const response = await generateText( { prompt: 'test_prompt@v1' } );
241
+
242
+ expect( response.text ).toBe( 'TEXT_FROM_GETTER' );
243
+ expect( response.result ).toBe( 'TEXT_FROM_GETTER' );
244
+ } );
245
+
246
+ it( 'generateText: passes through AI SDK options like tools and maxRetries', async () => {
247
+ const { generateText } = await importSut();
248
+ const mockTools = { calculator: { description: 'A calculator tool' } };
249
+
250
+ await generateText( {
251
+ prompt: 'test_prompt@v1',
252
+ tools: mockTools,
253
+ toolChoice: 'required',
254
+ maxRetries: 5,
255
+ seed: 42
256
+ } );
257
+
258
+ expect( aiFns.generateText ).toHaveBeenCalledWith(
259
+ expect.objectContaining( {
260
+ tools: mockTools,
261
+ toolChoice: 'required',
262
+ maxRetries: 5,
263
+ seed: 42
264
+ } )
265
+ );
266
+ } );
267
+
268
+ it( 'generateText: user-provided temperature overrides prompt temperature', async () => {
269
+ loadPromptImpl.mockReturnValueOnce( {
270
+ config: {
271
+ provider: 'openai',
272
+ model: 'gpt-4o',
273
+ temperature: 0.7
274
+ },
275
+ messages: [ { role: 'user', content: 'Hi' } ]
276
+ } );
277
+
278
+ const { generateText } = await importSut();
279
+ await generateText( { prompt: 'test_prompt@v1', temperature: 0.2 } );
280
+
281
+ expect( aiFns.generateText ).toHaveBeenCalledWith(
282
+ expect.objectContaining( { temperature: 0.2 } )
283
+ );
284
+ } );
285
+
286
+ it( 'generateText: passes through temperature: 0 from prompt', async () => {
287
+ loadPromptImpl.mockReturnValueOnce( {
288
+ config: {
289
+ provider: 'openai',
290
+ model: 'gpt-4o',
291
+ temperature: 0
292
+ },
293
+ messages: [ { role: 'user', content: 'Hi' } ]
294
+ } );
295
+
296
+ const { generateText } = await importSut();
297
+ await generateText( { prompt: 'test_prompt@v1' } );
298
+
299
+ expect( aiFns.generateText ).toHaveBeenCalledWith(
300
+ expect.objectContaining( { temperature: 0 } )
301
+ );
302
+ } );
303
+
304
+ it( 'generateText: .object returns undefined instead of leaking text', async () => {
305
+ const { generateText } = await importSut();
306
+ const result = await generateText( { prompt: 'test_prompt@v1' } );
307
+
308
+ expect( result.object ).toBeUndefined();
309
+ expect( result.text ).toBe( 'TEXT' );
310
+ expect( result.result ).toBe( 'TEXT' );
311
+ } );
312
+
313
+ it( 'generateText: passes through unknown future options for forward compatibility', async () => {
314
+ const { generateText } = await importSut();
315
+
316
+ await generateText( {
317
+ prompt: 'test_prompt@v1',
318
+ experimental_futureOption: { key: 'value' },
319
+ unknownOption: true
320
+ } );
321
+
322
+ expect( aiFns.generateText ).toHaveBeenCalledWith(
323
+ expect.objectContaining( {
324
+ experimental_futureOption: { key: 'value' },
325
+ unknownOption: true
326
+ } )
327
+ );
328
+ } );
329
+
330
+ it( 'streamText: validates, traces, calls AI streamText and returns stream result', async () => {
331
+ const { streamText } = await importSut();
332
+ const result = streamText( { prompt: 'test_prompt@v1' } );
333
+
334
+ expect( validators.validateStreamTextArgs ).toHaveBeenCalledWith( { prompt: 'test_prompt@v1' } );
335
+ expect( loadPromptImpl ).toHaveBeenCalledWith( 'test_prompt@v1', undefined );
336
+ expect( tracingSpies.addEventStart ).toHaveBeenCalledTimes( 1 );
337
+
338
+ expect( loadModelImpl ).toHaveBeenCalledWith( basePrompt );
339
+ expect( aiFns.streamText ).toHaveBeenCalledWith(
340
+ expect.objectContaining( {
341
+ model: 'MODEL',
342
+ messages: basePrompt.messages,
343
+ temperature: 0.3,
344
+ providerOptions: basePrompt.config.providerOptions,
345
+ onFinish: expect.any( Function ),
346
+ onError: expect.any( Function )
347
+ } )
348
+ );
349
+ expect( result.textStream ).toBe( 'MOCK_TEXT_STREAM' );
350
+ expect( result.fullStream ).toBe( 'MOCK_FULL_STREAM' );
351
+ } );
352
+
353
+ it( 'streamText: onFinish callback traces end event and calls user callback', async () => {
354
+ const { streamText } = await importSut();
355
+ const userOnFinish = vi.fn();
356
+
357
+ streamText( { prompt: 'test_prompt@v1', onFinish: userOnFinish } );
358
+
359
+ const callArgs = aiFns.streamText.mock.calls[0][0];
360
+ const usage = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
361
+ const finishEvent = {
362
+ text: 'STREAMED_TEXT',
363
+ usage,
364
+ totalUsage: usage,
365
+ providerMetadata: { anthropic: { cacheReadInputTokens: 50 } },
366
+ finishReason: 'stop'
367
+ };
368
+ await callArgs.onFinish( finishEvent );
369
+
370
+ expect( emitEventSpy ).toHaveBeenCalledTimes( 1 );
371
+ expect( emitEventSpy ).toHaveBeenCalledWith( 'llm:call_cost', {
372
+ modelId: basePrompt.config.model,
373
+ cost,
374
+ usage
375
+ } );
376
+ expect( tracingSpies.addEventEnd ).toHaveBeenCalledWith(
377
+ expect.objectContaining( {
378
+ details: {
379
+ result: 'STREAMED_TEXT',
380
+ usage,
381
+ cost,
382
+ providerMetadata: finishEvent.providerMetadata
383
+ }
384
+ } )
385
+ );
386
+ expect( userOnFinish ).toHaveBeenCalledWith( finishEvent );
387
+ } );
388
+
389
+ it( 'streamText: onError callback traces error and calls user callback', async () => {
390
+ const { streamText } = await importSut();
391
+ const userOnError = vi.fn();
392
+
393
+ streamText( { prompt: 'test_prompt@v1', onError: userOnError } );
394
+
395
+ const callArgs = aiFns.streamText.mock.calls[0][0];
396
+ const error = new Error( 'Stream failed' );
397
+ callArgs.onError( { error } );
398
+
399
+ expect( tracingSpies.addEventError ).toHaveBeenCalledWith(
400
+ expect.objectContaining( { details: error } )
401
+ );
402
+ expect( userOnError ).toHaveBeenCalledWith( { error } );
403
+ } );
404
+
405
+ it( 'streamText: works without user onFinish/onError callbacks', async () => {
406
+ const { streamText } = await importSut();
407
+
408
+ streamText( { prompt: 'test_prompt@v1' } );
409
+
410
+ const callArgs = aiFns.streamText.mock.calls[0][0];
411
+ const usage = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
412
+ const finishEvent = {
413
+ text: 'TEXT',
414
+ usage,
415
+ totalUsage: usage,
416
+ finishReason: 'stop'
417
+ };
418
+ await expect( callArgs.onFinish( finishEvent ) ).resolves.toBeUndefined();
419
+ expect( emitEventSpy ).toHaveBeenCalledWith( 'llm:call_cost', {
420
+ modelId: basePrompt.config.model,
421
+ cost,
422
+ usage
423
+ } );
424
+ expect( () => callArgs.onError( { error: new Error( 'fail' ) } ) ).not.toThrow();
425
+ } );
426
+
427
+ it( 'streamText: passes through AI SDK streaming options', async () => {
428
+ const { streamText } = await importSut();
429
+ const mockOnChunk = vi.fn();
430
+ const mockOnStepFinish = vi.fn();
431
+ const mockTransform = vi.fn();
432
+ const mockTools = { calculator: { description: 'A calculator tool' } };
433
+
434
+ streamText( {
435
+ prompt: 'test_prompt@v1',
436
+ tools: mockTools,
437
+ toolChoice: 'required',
438
+ maxRetries: 5,
439
+ onChunk: mockOnChunk,
440
+ onStepFinish: mockOnStepFinish,
441
+ experimental_transform: mockTransform
442
+ } );
443
+
444
+ expect( aiFns.streamText ).toHaveBeenCalledWith(
445
+ expect.objectContaining( {
446
+ tools: mockTools,
447
+ toolChoice: 'required',
448
+ maxRetries: 5,
449
+ onChunk: mockOnChunk,
450
+ onStepFinish: mockOnStepFinish,
451
+ experimental_transform: mockTransform
452
+ } )
453
+ );
454
+ } );
455
+
456
+ it( 'streamText: user onFinish/onError are not passed raw to AI SDK', async () => {
457
+ const { streamText } = await importSut();
458
+ const userOnFinish = vi.fn();
459
+ const userOnError = vi.fn();
460
+
461
+ streamText( { prompt: 'test_prompt@v1', onFinish: userOnFinish, onError: userOnError } );
462
+
463
+ const callArgs = aiFns.streamText.mock.calls[0][0];
464
+ expect( callArgs.onFinish ).not.toBe( userOnFinish );
465
+ expect( callArgs.onError ).not.toBe( userOnError );
466
+ } );
467
+
468
+ it( 'streamText: validation failure propagates synchronously', async () => {
469
+ const validationError = new Error( 'prompt is required' );
470
+ validators.validateStreamTextArgs.mockImplementationOnce( () => {
471
+ throw validationError;
472
+ } );
473
+ const { streamText } = await importSut();
474
+
475
+ expect( () => streamText( { prompt: '' } ) ).toThrow( validationError );
476
+ expect( aiFns.streamText ).not.toHaveBeenCalled();
477
+ } );
478
+
479
+ it( 'streamText: trace start event includes correct name and details', async () => {
480
+ const { streamText } = await importSut();
481
+ const vars = { topic: 'testing' };
482
+
483
+ streamText( { prompt: 'test_prompt@v1', variables: vars } );
484
+
485
+ expect( tracingSpies.addEventStart ).toHaveBeenCalledWith( {
486
+ kind: 'llm',
487
+ name: 'streamText',
488
+ id: expect.stringContaining( 'streamText-' ),
489
+ details: {
490
+ prompt: 'test_prompt@v1',
491
+ variables: vars,
492
+ loadedPrompt: basePrompt
493
+ }
494
+ } );
495
+ } );
496
+
497
+ it( 'streamText: traces error and rethrows when AI.streamText throws synchronously', async () => {
498
+ const syncError = new Error( 'Invalid model config' );
499
+ aiFns.streamText.mockImplementation( () => {
500
+ throw syncError;
501
+ } );
502
+ const { streamText } = await importSut();
503
+
504
+ expect( () => streamText( { prompt: 'test_prompt@v1' } ) ).toThrow( syncError );
505
+ expect( tracingSpies.addEventError ).toHaveBeenCalledWith(
506
+ expect.objectContaining( { details: syncError } )
507
+ );
508
+ } );
509
+
510
+ it( 'streamText: passes variables to prompt loader', async () => {
511
+ const { streamText } = await importSut();
512
+ const vars = { name: 'World', count: 5 };
513
+
514
+ streamText( { prompt: 'test_prompt@v1', variables: vars } );
515
+
516
+ expect( loadPromptImpl ).toHaveBeenCalledWith( 'test_prompt@v1', vars );
517
+ } );
518
+
519
+ it( 'generateText: merges tool-extracted sources into response.sources', async () => {
520
+ const extracted = [
521
+ { type: 'source', sourceType: 'url', id: 'abc123', url: 'https://tool.com/1', title: 'Tool 1' },
522
+ { type: 'source', sourceType: 'url', id: 'def456', url: 'https://tool.com/2', title: 'Tool 2' }
523
+ ];
524
+ extractSourcesFromStepsImpl.mockReturnValue( extracted );
525
+
526
+ const usageTools = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
527
+ aiFns.generateText.mockResolvedValueOnce( {
528
+ text: 'answer',
529
+ sources: [],
530
+ steps: [ { toolResults: [] } ],
531
+ usage: usageTools,
532
+ totalUsage: usageTools,
533
+ finishReason: 'stop'
534
+ } );
535
+
536
+ const { generateText } = await importSut();
537
+ const result = await generateText( { prompt: 'test_prompt@v1' } );
538
+
539
+ expect( result.sources ).toEqual( extracted );
540
+ } );
541
+
542
+ it( 'generateText: deduplicates extracted sources against native sources', async () => {
543
+ const nativeSources = [
544
+ { type: 'source', sourceType: 'url', id: 'native1', url: 'https://shared.com', title: 'Native' }
545
+ ];
546
+ const extracted = [
547
+ { type: 'source', sourceType: 'url', id: 'ext1', url: 'https://shared.com', title: 'Extracted' },
548
+ { type: 'source', sourceType: 'url', id: 'ext2', url: 'https://unique.com', title: 'Unique' }
549
+ ];
550
+ extractSourcesFromStepsImpl.mockReturnValue( extracted );
551
+
552
+ const usageDedup = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
553
+ aiFns.generateText.mockResolvedValueOnce( {
554
+ text: 'answer',
555
+ sources: nativeSources,
556
+ steps: [ { toolResults: [] } ],
557
+ usage: usageDedup,
558
+ totalUsage: usageDedup,
559
+ finishReason: 'stop'
560
+ } );
561
+
562
+ const { generateText } = await importSut();
563
+ const result = await generateText( { prompt: 'test_prompt@v1' } );
564
+
565
+ expect( result.sources ).toHaveLength( 2 );
566
+ expect( result.sources[0].url ).toBe( 'https://shared.com' );
567
+ expect( result.sources[0].title ).toBe( 'Native' );
568
+ expect( result.sources[1].url ).toBe( 'https://unique.com' );
569
+ } );
570
+
571
+ it( 'generateText: returns native sources unchanged when no tool sources extracted', async () => {
572
+ const nativeSources = [
573
+ { type: 'source', sourceType: 'url', id: 'n1', url: 'https://native.com', title: 'Native' }
574
+ ];
575
+ extractSourcesFromStepsImpl.mockReturnValue( [] );
576
+
577
+ const usageNative = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
578
+ aiFns.generateText.mockResolvedValueOnce( {
579
+ text: 'answer',
580
+ sources: nativeSources,
581
+ usage: usageNative,
582
+ totalUsage: usageNative,
583
+ finishReason: 'stop'
584
+ } );
585
+
586
+ const { generateText } = await importSut();
587
+ const result = await generateText( { prompt: 'test_prompt@v1' } );
588
+
589
+ expect( result.sources ).toEqual( nativeSources );
590
+ } );
591
+
592
+ it( 'generateText: includes costs from cost module in trace details', async () => {
593
+ const customCost = { total: 0.02, components: { input: { value: 0.01 }, output: { value: 0.01 } } };
594
+ calculateLLMCallCostImpl.mockResolvedValueOnce( customCost );
595
+
596
+ const { generateText } = await importSut();
597
+ await generateText( { prompt: 'test_prompt@v1' } );
598
+
599
+ expect( calculateLLMCallCostImpl ).toHaveBeenCalledWith( {
600
+ usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
601
+ modelId: basePrompt.config.model
602
+ } );
603
+ expect( tracingSpies.addEventEnd ).toHaveBeenCalledWith(
604
+ expect.objectContaining( { details: expect.objectContaining( { cost: customCost } ) } )
605
+ );
606
+ } );
607
+
608
+ it( 'generateText: includes sourcesFromTools in trace details', async () => {
609
+ const extracted = [
610
+ { type: 'source', sourceType: 'url', id: 'abc', url: 'https://t.com', title: 'T' }
611
+ ];
612
+ extractSourcesFromStepsImpl.mockReturnValue( extracted );
613
+
614
+ const usageSources = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
615
+ aiFns.generateText.mockResolvedValueOnce( {
616
+ text: 'TEXT',
617
+ sources: [],
618
+ steps: [],
619
+ usage: usageSources,
620
+ totalUsage: usageSources,
621
+ finishReason: 'stop'
622
+ } );
623
+
624
+ const { generateText } = await importSut();
625
+ await generateText( { prompt: 'test_prompt@v1' } );
626
+
627
+ expect( tracingSpies.addEventEnd ).toHaveBeenCalledWith(
628
+ expect.objectContaining( {
629
+ details: expect.objectContaining( { sourcesFromTools: extracted } )
630
+ } )
631
+ );
632
+ } );
633
+ } );
@@ -0,0 +1,38 @@
1
+ const costTableUrl = 'https://models.dev/api.json';
2
+ const cacheTTL = 1000 * 60 * 60 * 24; // 1 day
3
+
4
+ export const cache = {
5
+ content: null,
6
+ expiresAt: 0
7
+ };
8
+
9
+ const buildModelMap = data => {
10
+ const map = new Map();
11
+ for ( const provider of Object.values( data ) ) {
12
+ for ( const [ modelName, { cost } ] of Object.entries( provider.models ?? {} ) ) {
13
+ if ( cost ) { // some models don't have cost
14
+ map.set( modelName, cost );
15
+ map.set( `${provider.id}/${modelName}`, cost );
16
+ }
17
+ }
18
+ }
19
+ return map;
20
+ };
21
+
22
+ export const fetchModelsPricing = async () => {
23
+ if ( cache.content && cache.expiresAt > Date.now() ) {
24
+ return cache.content;
25
+ }
26
+ const res = await fetch( costTableUrl );
27
+ if ( !res.ok ) {
28
+ if ( cache.content ) {
29
+ console.warn( `Error ${res.status} when fetching models pricing at ${costTableUrl}, falling back to stale cache` );
30
+ return cache.content;
31
+ }
32
+ console.error( `Error ${res.status} when fetching models pricing at ${costTableUrl}` );
33
+ return null;
34
+ }
35
+ cache.content = buildModelMap( await res.json() );
36
+ cache.expiresAt = Date.now() + cacheTTL;
37
+ return cache.content;
38
+ };