@outputai/llm 0.11.1-next.da26845.0 → 0.12.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 (47) hide show
  1. package/package.json +7 -3
  2. package/src/agent.js +7 -2
  3. package/src/generate.js +1 -0
  4. package/src/index.d.ts +14 -6
  5. package/src/utils/wrap.js +35 -2
  6. package/src/agent.spec.js +0 -595
  7. package/src/ai_provider.spec.js +0 -217
  8. package/src/ai_sdk_options.spec.js +0 -390
  9. package/src/fixtures/image_response_v7_google_vertex.js +0 -72
  10. package/src/fixtures/image_response_v7_openai.js +0 -77
  11. package/src/fixtures/models_api_light.json +0 -675
  12. package/src/fixtures/models_api_v7_subset.json +0 -259
  13. package/src/fixtures/stream_response_v7_anthropic.js +0 -389
  14. package/src/fixtures/stream_response_v7_google_vertex.js +0 -462
  15. package/src/fixtures/stream_response_v7_openai.js +0 -353
  16. package/src/fixtures/stream_response_v7_perplexity.js +0 -671
  17. package/src/fixtures/text_response_v7_anthropic.js +0 -359
  18. package/src/fixtures/text_response_v7_anthropic_cache.js +0 -719
  19. package/src/fixtures/text_response_v7_google_vertex.js +0 -435
  20. package/src/fixtures/text_response_v7_openai.js +0 -347
  21. package/src/fixtures/text_response_v7_perplexity.js +0 -764
  22. package/src/generate.spec.js +0 -525
  23. package/src/prompt/content.spec.js +0 -208
  24. package/src/prompt/interpolations.spec.js +0 -109
  25. package/src/prompt/loader.full.spec.js +0 -309
  26. package/src/prompt/loader.spec.js +0 -258
  27. package/src/prompt/markup/attributes.spec.js +0 -132
  28. package/src/prompt/markup/nodes.spec.js +0 -333
  29. package/src/prompt/markup/tokenizer.spec.js +0 -149
  30. package/src/prompt/markup/tokens.spec.js +0 -250
  31. package/src/prompt/validations.spec.js +0 -812
  32. package/src/utils/cost.full.spec.js +0 -272
  33. package/src/utils/cost.spec.js +0 -290
  34. package/src/utils/error_handler.spec.js +0 -230
  35. package/src/utils/file.spec.js +0 -89
  36. package/src/utils/image.spec.js +0 -20
  37. package/src/utils/legacy_cost_attribute.spec.js +0 -278
  38. package/src/utils/models.spec.js +0 -119
  39. package/src/utils/models_pricing.spec.js +0 -162
  40. package/src/utils/skills.spec.js +0 -168
  41. package/src/utils/sources.spec.js +0 -122
  42. package/src/utils/stream.spec.js +0 -55
  43. package/src/utils/tools.spec.js +0 -167
  44. package/src/utils/usage.full.spec.js +0 -215
  45. package/src/utils/usage.spec.js +0 -246
  46. package/src/utils/wrap.spec.js +0 -467
  47. package/src/validations.spec.js +0 -631
@@ -1,467 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import imageResponseFixture from '../fixtures/image_response_v7_openai.js';
3
- import streamResponseFixture from '../fixtures/stream_response_v7_openai.js';
4
- import textResponseFixture from '../fixtures/text_response_v7_openai.js';
5
-
6
- const mocks = vi.hoisted( () => ( {
7
- extractSources: vi.fn(),
8
- parseLLMUsage: vi.fn(),
9
- calculateCosts: vi.fn(),
10
- convertCostToLegacy: vi.fn(),
11
- calculateBase64FileSize: vi.fn(),
12
- mapAiError: vi.fn()
13
- } ) );
14
-
15
- vi.mock( './sources.js', () => ( {
16
- extractSources: mocks.extractSources
17
- } ) );
18
-
19
- vi.mock( './usage.js', () => ( {
20
- parseLLMUsage: mocks.parseLLMUsage
21
- } ) );
22
-
23
- vi.mock( './cost.js', () => ( {
24
- calculateCosts: mocks.calculateCosts
25
- } ) );
26
-
27
- vi.mock( './legacy_cost_attribute.js', () => ( {
28
- convertCostToLegacy: mocks.convertCostToLegacy
29
- } ) );
30
-
31
- vi.mock( './image.js', () => ( {
32
- calculateBase64FileSize: mocks.calculateBase64FileSize
33
- } ) );
34
-
35
- vi.mock( './error_handler.js', () => ( {
36
- mapAiError: mocks.mapAiError
37
- } ) );
38
-
39
- vi.mock( '@outputai/core/sdk/runtime', () => ( {
40
- Tracing: {
41
- addEventStart: vi.fn(),
42
- addEventError: vi.fn(),
43
- addEventAttribute: vi.fn(),
44
- addEventEnd: vi.fn()
45
- },
46
- Event: {
47
- emit: vi.fn()
48
- }
49
- } ) );
50
-
51
- import { Tracing, Event } from '@outputai/core/sdk/runtime';
52
- import { wrapGeneration, wrapStream } from './wrap.js';
53
-
54
- const tracing = vi.mocked( Tracing, true );
55
- const event = vi.mocked( Event, true );
56
-
57
- const clone = value => structuredClone( value );
58
- const textResponse = () => clone( textResponseFixture );
59
- const streamResponse = () => clone( streamResponseFixture );
60
- const imageResponse = () => clone( imageResponseFixture );
61
-
62
- const prompt = {
63
- name: 'writer@v1',
64
- config: { provider: 'openai', model: 'test-model' }
65
- };
66
-
67
- const mockUsage = {
68
- type: 'llm:generation:usage',
69
- providerId: 'openai',
70
- modelId: 'test-model',
71
- status: 'complete',
72
- input: 2,
73
- output: 1,
74
- total: 3,
75
- items: []
76
- };
77
- const mockCost = { type: 'llm:generation:cost', total: 0.001, items: [] };
78
- const mockLegacyCost = { type: 'llm:usage', modelId: 'test-model', usage: [], total: 0.001, tokensUsed: 0 };
79
- const mappedError = new Error( 'mapped' );
80
-
81
- describe( 'wrapGeneration / wrapStream', () => {
82
- beforeEach( () => {
83
- vi.clearAllMocks();
84
- vi.spyOn( Date, 'now' ).mockReturnValue( 9_000_000_000 );
85
- mocks.extractSources.mockReturnValue( [] );
86
- mocks.parseLLMUsage.mockReturnValue( mockUsage );
87
- mocks.calculateCosts.mockResolvedValue( mockCost );
88
- mocks.convertCostToLegacy.mockReturnValue( mockLegacyCost );
89
- mocks.calculateBase64FileSize.mockReturnValue( 1234 );
90
- mocks.mapAiError.mockReturnValue( mappedError );
91
- } );
92
-
93
- afterEach( () => {
94
- vi.restoreAllMocks();
95
- } );
96
-
97
- describe( 'wrapGeneration', () => {
98
- it( 'starts an llm trace, wraps a text response, and ends with raw usage and sources', async () => {
99
- const structuredCloneSpy = vi.spyOn( globalThis, 'structuredClone' );
100
- const response = textResponse();
101
- const mergedSources = [ { url: 'https://merged.test' } ];
102
- mocks.extractSources.mockReturnValue( mergedSources );
103
-
104
- const wrapped = await wrapGeneration( {
105
- name: 'generateText',
106
- prompt,
107
- fn: async () => response
108
- } );
109
-
110
- expect( tracing.addEventStart ).toHaveBeenCalledWith( {
111
- kind: 'llm',
112
- id: 'generateText-9000000000',
113
- name: 'generateText',
114
- details: { prompt }
115
- } );
116
- expect( mocks.parseLLMUsage ).toHaveBeenCalledWith( {
117
- usage: response.usage,
118
- prompt
119
- } );
120
- expect( mocks.calculateCosts ).toHaveBeenCalledWith( mockUsage );
121
- expect( tracing.addEventAttribute ).toHaveBeenNthCalledWith( 1, {
122
- eventId: 'generateText-9000000000',
123
- attribute: mockCost
124
- } );
125
- expect( tracing.addEventAttribute ).toHaveBeenNthCalledWith( 2, {
126
- eventId: 'generateText-9000000000',
127
- attribute: mockLegacyCost
128
- } );
129
- expect( tracing.addEventAttribute ).toHaveBeenNthCalledWith( 3, {
130
- eventId: 'generateText-9000000000',
131
- attribute: mockUsage
132
- } );
133
- expect( mocks.convertCostToLegacy ).toHaveBeenCalledWith( mockCost );
134
- expect( event.emit ).toHaveBeenNthCalledWith( 1, 'cost:llm:request', mockLegacyCost );
135
- expect( event.emit ).toHaveBeenNthCalledWith( 2, 'llm:generation:metering', {
136
- cost: mockCost,
137
- usage: mockUsage
138
- } );
139
- expect( structuredCloneSpy ).toHaveBeenCalledWith( mockLegacyCost );
140
- expect( structuredCloneSpy ).toHaveBeenCalledWith( { cost: mockCost, usage: mockUsage } );
141
- const legacyEventPayload = event.emit.mock.calls[0][1];
142
- const meteringEventPayload = event.emit.mock.calls[1][1];
143
- expect( legacyEventPayload ).not.toBe( mockLegacyCost );
144
- expect( meteringEventPayload.cost ).not.toBe( mockCost );
145
- expect( meteringEventPayload.usage ).not.toBe( mockUsage );
146
- expect( mocks.extractSources ).toHaveBeenCalledWith( response );
147
- expect( tracing.addEventEnd ).toHaveBeenCalledWith( {
148
- id: 'generateText-9000000000',
149
- details: {
150
- result: response.text,
151
- usage: response.usage,
152
- providerMetadata: response.finalStep.providerMetadata,
153
- sources: mergedSources
154
- }
155
- } );
156
- expect( wrapped.result ).toBe( response.text );
157
- expect( wrapped.cost ).toEqual( mockCost );
158
- expect( wrapped.sources ).toBe( mergedSources );
159
- expect( wrapped.text ).toBe( response.text );
160
- } );
161
-
162
- it( 'uses usage instead of deprecated totalUsage', async () => {
163
- const response = {
164
- text: 'hi',
165
- usage: { inputTokens: 2 },
166
- totalUsage: { inputTokens: 99 },
167
- finalStep: { providerMetadata: undefined },
168
- sources: []
169
- };
170
-
171
- await wrapGeneration( { name: 'generateText', prompt, fn: async () => response } );
172
-
173
- expect( mocks.parseLLMUsage ).toHaveBeenCalledWith( {
174
- usage: { inputTokens: 2 },
175
- prompt
176
- } );
177
- expect( mocks.calculateCosts ).toHaveBeenCalledWith( mockUsage );
178
- } );
179
-
180
- it( 'wraps an image response using usage and mapped image metadata', async () => {
181
- const response = imageResponse();
182
-
183
- const wrapped = await wrapGeneration( {
184
- name: 'generateImage',
185
- prompt,
186
- fn: async () => response
187
- } );
188
-
189
- expect( mocks.parseLLMUsage ).toHaveBeenCalledWith( {
190
- usage: response.usage,
191
- prompt
192
- } );
193
- expect( mocks.calculateCosts ).toHaveBeenCalledWith( mockUsage );
194
- expect( mocks.calculateBase64FileSize ).toHaveBeenCalledWith( response.images[0].base64Data );
195
- expect( tracing.addEventEnd ).toHaveBeenCalledWith( {
196
- id: 'generateImage-9000000000',
197
- details: {
198
- result: [ { size: 1234, mediaType: 'image/png' } ],
199
- usage: response.usage,
200
- providerMetadata: response.providerMetadata
201
- }
202
- } );
203
- expect( mocks.extractSources ).not.toHaveBeenCalled();
204
- expect( wrapped.result ).toBe( response.image );
205
- expect( wrapped.cost ).toEqual( mockCost );
206
- } );
207
-
208
- it( 'emits normalized usage when cost is missing', async () => {
209
- mocks.calculateCosts.mockResolvedValue( null );
210
- const response = textResponse();
211
-
212
- const wrapped = await wrapGeneration( {
213
- name: 'generateText',
214
- prompt,
215
- fn: async () => response
216
- } );
217
-
218
- expect( tracing.addEventAttribute ).toHaveBeenCalledWith( {
219
- eventId: 'generateText-9000000000',
220
- attribute: mockUsage
221
- } );
222
- expect( event.emit ).toHaveBeenCalledOnce();
223
- expect( event.emit ).toHaveBeenCalledWith( 'llm:generation:metering', {
224
- cost: null,
225
- usage: mockUsage
226
- } );
227
- expect( tracing.addEventEnd ).toHaveBeenCalledWith( {
228
- id: 'generateText-9000000000',
229
- details: {
230
- result: response.text,
231
- usage: response.usage,
232
- providerMetadata: response.finalStep.providerMetadata,
233
- sources: []
234
- }
235
- } );
236
- expect( wrapped.cost ).toBeNull();
237
- } );
238
-
239
- it( 'skips the legacy attribute and event when the cost cannot be converted', async () => {
240
- mocks.convertCostToLegacy.mockReturnValue( null );
241
- const response = textResponse();
242
-
243
- await wrapGeneration( {
244
- name: 'generateText',
245
- prompt,
246
- fn: async () => response
247
- } );
248
-
249
- expect( tracing.addEventAttribute ).toHaveBeenNthCalledWith( 1, {
250
- eventId: 'generateText-9000000000',
251
- attribute: mockCost
252
- } );
253
- expect( tracing.addEventAttribute ).toHaveBeenNthCalledWith( 2, {
254
- eventId: 'generateText-9000000000',
255
- attribute: mockUsage
256
- } );
257
- expect( tracing.addEventAttribute ).toHaveBeenCalledTimes( 2 );
258
- expect( event.emit ).toHaveBeenCalledOnce();
259
- expect( event.emit ).toHaveBeenCalledWith( 'llm:generation:metering', {
260
- cost: mockCost,
261
- usage: mockUsage
262
- } );
263
- } );
264
-
265
- it( 'skips cost calculation when usage cannot be parsed', async () => {
266
- mocks.parseLLMUsage.mockReturnValue( null );
267
- const response = textResponse();
268
-
269
- const wrapped = await wrapGeneration( {
270
- name: 'generateText',
271
- prompt,
272
- fn: async () => response
273
- } );
274
-
275
- expect( mocks.calculateCosts ).not.toHaveBeenCalled();
276
- expect( tracing.addEventAttribute ).not.toHaveBeenCalled();
277
- expect( event.emit ).not.toHaveBeenCalled();
278
- expect( tracing.addEventEnd ).toHaveBeenCalledWith( {
279
- id: 'generateText-9000000000',
280
- details: {
281
- result: response.text,
282
- usage: response.usage,
283
- providerMetadata: response.finalStep.providerMetadata,
284
- sources: []
285
- }
286
- } );
287
- expect( wrapped.cost ).toBeNull();
288
- } );
289
-
290
- it( 'maps fn errors onto the llm trace and rethrows', async () => {
291
- const original = new Error( 'boom' );
292
-
293
- await expect( wrapGeneration( {
294
- name: 'Agent.generate',
295
- prompt,
296
- fn: async () => {
297
- throw original;
298
- }
299
- } ) ).rejects.toBe( mappedError );
300
-
301
- expect( mocks.mapAiError ).toHaveBeenCalledWith( original );
302
- expect( tracing.addEventError ).toHaveBeenCalledWith( {
303
- id: 'Agent.generate-9000000000',
304
- details: mappedError
305
- } );
306
- expect( tracing.addEventEnd ).not.toHaveBeenCalled();
307
- } );
308
- } );
309
-
310
- describe( 'wrapStream', () => {
311
- const hooksFrom = name => {
312
- const fn = vi.fn( () => ( {} ) );
313
- wrapStream( { name, prompt, fn } );
314
- return fn.mock.calls[0][0];
315
- };
316
-
317
- it( 'starts an llm trace, returns fn result, and passes hooks', () => {
318
- const stream = { kind: 'stream' };
319
- const fn = vi.fn( () => stream );
320
-
321
- const result = wrapStream( { name: 'streamText', prompt, fn } );
322
-
323
- expect( result ).toBe( stream );
324
- expect( tracing.addEventStart ).toHaveBeenCalledWith( {
325
- kind: 'llm',
326
- id: 'streamText-9000000000',
327
- name: 'streamText',
328
- details: { prompt }
329
- } );
330
- expect( fn ).toHaveBeenCalledWith( {
331
- onEndHook: expect.any( Function ),
332
- onErrorHook: expect.any( Function )
333
- } );
334
- expect( tracing.addEventEnd ).not.toHaveBeenCalled();
335
- } );
336
-
337
- it( 'wraps onEnd, ends the trace, then awaits the callback', async () => {
338
- const response = streamResponse();
339
- const mergedSources = [ { url: 'https://s.test' } ];
340
- mocks.extractSources.mockReturnValue( mergedSources );
341
- const callback = vi.fn();
342
- const { onEndHook } = hooksFrom( 'Agent.stream' );
343
-
344
- await onEndHook( response, callback );
345
-
346
- expect( mocks.parseLLMUsage ).toHaveBeenCalledWith( {
347
- usage: response.usage,
348
- prompt
349
- } );
350
- expect( mocks.calculateCosts ).toHaveBeenCalledWith( mockUsage );
351
- expect( mocks.extractSources ).toHaveBeenCalledWith( response );
352
- expect( callback ).toHaveBeenCalledOnce();
353
- const proxied = callback.mock.calls[0][0];
354
- expect( proxied.result ).toBe( response.text );
355
- expect( proxied.cost ).toEqual( mockCost );
356
- expect( proxied.sources ).toBe( mergedSources );
357
- expect( tracing.addEventEnd ).toHaveBeenCalledWith( {
358
- id: 'Agent.stream-9000000000',
359
- details: {
360
- result: response.text,
361
- usage: response.usage,
362
- providerMetadata: response.finalStep.providerMetadata,
363
- sources: mergedSources
364
- }
365
- } );
366
- expect( tracing.addEventEnd.mock.invocationCallOrder[0] ).toBeLessThan( callback.mock.invocationCallOrder[0] );
367
- } );
368
-
369
- it( 'ends the trace and swallows throws from the onEnd callback', async () => {
370
- const response = textResponse();
371
- const { onEndHook } = hooksFrom( 'Agent.stream' );
372
-
373
- await expect( onEndHook( response, async () => {
374
- throw new Error( 'user onEnd' );
375
- } ) ).resolves.toBeUndefined();
376
-
377
- expect( tracing.addEventEnd ).toHaveBeenCalledOnce();
378
- expect( tracing.addEventError ).not.toHaveBeenCalled();
379
- expect( mocks.mapAiError ).not.toHaveBeenCalled();
380
- } );
381
-
382
- it( 'maps onError events and forwards the mapped error to the callback', () => {
383
- const original = new Error( 'stream failed' );
384
- const callback = vi.fn();
385
- const { onErrorHook } = hooksFrom( 'streamText' );
386
-
387
- onErrorHook( { error: original }, callback );
388
-
389
- expect( mocks.mapAiError ).toHaveBeenCalledWith( original );
390
- expect( tracing.addEventError ).toHaveBeenCalledWith( {
391
- id: 'streamText-9000000000',
392
- details: mappedError
393
- } );
394
- expect( callback ).toHaveBeenCalledWith( mappedError );
395
- } );
396
-
397
- it( 'swallows throws from the onError callback', async () => {
398
- const { onErrorHook } = hooksFrom( 'streamText' );
399
-
400
- await expect( onErrorHook( { error: new Error( 'x' ) }, () => {
401
- throw new Error( 'user onError' );
402
- } ) ).resolves.toBeUndefined();
403
- } );
404
-
405
- it( 'awaits and swallows rejected promises from the onError callback', async () => {
406
- const state = { rejection: null };
407
- const callback = vi.fn( () => new Promise( ( _, reject ) => {
408
- state.rejection = reject;
409
- } ) );
410
- const { onErrorHook } = hooksFrom( 'streamText' );
411
-
412
- const hookPromise = onErrorHook( { error: new Error( 'x' ) }, callback );
413
-
414
- expect( hookPromise ).toBeInstanceOf( Promise );
415
- state.rejection( new Error( 'async user onError' ) );
416
- await expect( hookPromise ).resolves.toBeUndefined();
417
- } );
418
-
419
- it( 'maps a throw from fn onto the llm trace and rethrows', () => {
420
- const original = new Error( 'create stream' );
421
-
422
- expect( () => wrapStream( {
423
- name: 'streamText',
424
- prompt,
425
- fn: () => {
426
- throw original;
427
- }
428
- } ) ).toThrow( mappedError );
429
-
430
- expect( mocks.mapAiError ).toHaveBeenCalledWith( original );
431
- expect( tracing.addEventError ).toHaveBeenCalledWith( {
432
- id: 'streamText-9000000000',
433
- details: mappedError
434
- } );
435
- } );
436
-
437
- it( 'maps a rejected promise from fn onto the llm trace and rethrows', async () => {
438
- const original = new Error( 'prepareCall failed' );
439
-
440
- await expect( wrapStream( {
441
- name: 'Agent.stream',
442
- prompt,
443
- fn: () => Promise.reject( original )
444
- } ) ).rejects.toBe( mappedError );
445
-
446
- expect( mocks.mapAiError ).toHaveBeenCalledWith( original );
447
- expect( tracing.addEventError ).toHaveBeenCalledWith( {
448
- id: 'Agent.stream-9000000000',
449
- details: mappedError
450
- } );
451
- expect( tracing.addEventEnd ).not.toHaveBeenCalled();
452
- } );
453
-
454
- it( 'returns a fulfilled promise from fn without mapping', async () => {
455
- const stream = { kind: 'stream' };
456
-
457
- await expect( wrapStream( {
458
- name: 'Agent.stream',
459
- prompt,
460
- fn: () => Promise.resolve( stream )
461
- } ) ).resolves.toBe( stream );
462
-
463
- expect( mocks.mapAiError ).not.toHaveBeenCalled();
464
- expect( tracing.addEventError ).not.toHaveBeenCalled();
465
- } );
466
- } );
467
- } );