@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.
- package/LICENSE +201 -0
- package/README.md +11 -0
- package/package.json +31 -0
- package/src/ai_model.js +130 -0
- package/src/ai_model.spec.js +779 -0
- package/src/ai_sdk.js +145 -0
- package/src/ai_sdk.spec.js +633 -0
- package/src/cost/fetch_models_pricing.js +38 -0
- package/src/cost/fetch_models_pricing.spec.js +121 -0
- package/src/cost/fixtures/models_api_light.json +661 -0
- package/src/cost/index.js +73 -0
- package/src/cost/index.spec.js +162 -0
- package/src/index.d.ts +309 -0
- package/src/index.js +8 -0
- package/src/load_content.js +43 -0
- package/src/load_content.spec.js +81 -0
- package/src/parser.js +28 -0
- package/src/parser.spec.js +122 -0
- package/src/prompt_loader.js +45 -0
- package/src/prompt_loader.spec.js +179 -0
- package/src/prompt_loader_validation.spec.js +95 -0
- package/src/prompt_validations.js +60 -0
- package/src/prompt_validations.spec.js +346 -0
- package/src/source_extraction.js +49 -0
- package/src/source_extraction.spec.js +169 -0
- package/src/validations.js +21 -0
|
@@ -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
|
+
};
|