@outputai/llm 0.4.1-dev.c0b98d8.0 → 0.4.1-next.43c9293.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/llm",
3
- "version": "0.4.1-dev.c0b98d8.0",
3
+ "version": "0.4.1-next.43c9293.0",
4
4
  "description": "Framework abstraction to interact with LLM models",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -19,11 +19,11 @@
19
19
  "@perplexity-ai/ai-sdk": "0.1.3",
20
20
  "@tavily/ai-sdk": "0.4.1",
21
21
  "ai": "6.0.168",
22
- "decimal.js": "10.6.0",
23
22
  "entities": "8.0.0",
24
23
  "gray-matter": "4.0.3",
25
24
  "liquidjs": "10.25.7",
26
- "@outputai/core": "0.4.1-dev.c0b98d8.0"
25
+ "undici": "8.1.0",
26
+ "@outputai/core": "0.4.1-next.43c9293.0"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "publishConfig": {
package/src/ai_model.js CHANGED
@@ -1,12 +1,29 @@
1
- import { bedrock } from '@ai-sdk/amazon-bedrock';
2
- import { anthropic } from '@ai-sdk/anthropic';
3
- import { azure } from '@ai-sdk/azure';
4
- import { vertex } from '@ai-sdk/google-vertex';
5
- import { openai } from '@ai-sdk/openai';
6
- import { perplexity } from '@ai-sdk/perplexity';
1
+ import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
2
+ import { createAnthropic } from '@ai-sdk/anthropic';
3
+ import { createAzure } from '@ai-sdk/azure';
4
+ import { createVertex } from '@ai-sdk/google-vertex';
5
+ import { createOpenAI } from '@ai-sdk/openai';
6
+ import { createPerplexity } from '@ai-sdk/perplexity';
7
7
  import { ValidationError, z } from '@outputai/core';
8
+ import { Agent, fetch } from 'undici';
9
+
10
+ const dispatcher = new Agent( {
11
+ headersTimeout: 15 * 60 * 1000, // 15 min
12
+ bodyTimeout: 15 * 60 * 1000
13
+ } );
14
+
15
+ const customFetch = ( input, init ) => fetch( input, { dispatcher, ...init } );
16
+ const initProvider = factory => factory( { fetch: customFetch } );
17
+
18
+ export const builtInProviders = {
19
+ azure: initProvider( createAzure ),
20
+ anthropic: initProvider( createAnthropic ),
21
+ openai: initProvider( createOpenAI ),
22
+ vertex: initProvider( createVertex ),
23
+ bedrock: initProvider( createAmazonBedrock ),
24
+ perplexity: initProvider( createPerplexity )
25
+ };
8
26
 
9
- export const builtInProviders = { azure, anthropic, openai, vertex, bedrock, perplexity };
10
27
  export const providers = { ...builtInProviders };
11
28
 
12
29
  const registerProviderSchema = z.object( {
@@ -14,20 +31,17 @@ const registerProviderSchema = z.object( {
14
31
  providerFn: z.function()
15
32
  } );
16
33
 
34
+ const toolConfigSchema = z.record( z.string(), z.unknown() );
35
+
17
36
  export function registerProvider( name, providerFn ) {
18
37
  const result = registerProviderSchema.safeParse( { name, providerFn } );
19
38
  if ( !result.success ) {
20
- throw new ValidationError(
21
- `Invalid provider registration: ${z.prettifyError( result.error )}`,
22
- { cause: result.error }
23
- );
39
+ throw new ValidationError( `Invalid provider registration: ${z.prettifyError( result.error )}` );
24
40
  }
25
41
  providers[name] = providerFn;
26
42
  }
27
43
 
28
- export function getRegisteredProviders() {
29
- return Object.keys( providers );
30
- }
44
+ export const getRegisteredProviders = () => Object.keys( providers );
31
45
 
32
46
  export function loadModel( prompt ) {
33
47
  const config = prompt?.config;
@@ -49,10 +63,8 @@ export function loadModel( prompt ) {
49
63
  const provider = providers[providerName];
50
64
 
51
65
  if ( !provider ) {
52
- const validProviders = Object.keys( providers ).join( ', ' );
53
- throw new Error(
54
- `Invalid provider "${providerName}". Valid providers: ${validProviders}`
55
- );
66
+ const availableProviders = Object.keys( providers ).join( ', ' );
67
+ throw new Error( `Invalid provider "${providerName}". Valid providers: ${availableProviders}` );
56
68
  }
57
69
 
58
70
  return provider( modelName );
@@ -60,7 +72,12 @@ export function loadModel( prompt ) {
60
72
 
61
73
  export function loadTools( prompt ) {
62
74
  const config = prompt?.config;
63
- const toolsConfig = config?.tools;
75
+
76
+ if ( !config ) {
77
+ throw new Error( 'Prompt is missing config object' );
78
+ }
79
+
80
+ const { tools: toolsConfig, provider: providerName } = config;
64
81
 
65
82
  if ( !toolsConfig ) {
66
83
  return null;
@@ -84,20 +101,15 @@ export function loadTools( prompt ) {
84
101
  return null;
85
102
  }
86
103
 
87
- const providerName = config.provider;
88
104
  const provider = providers[providerName];
89
105
 
90
106
  if ( !provider ) {
91
- const validProviders = Object.keys( providers ).join( ', ' );
92
- throw new Error(
93
- `Invalid provider "${providerName}". Valid providers: ${validProviders}`
94
- );
107
+ const availableProviders = Object.keys( providers ).join( ', ' );
108
+ throw new Error( `Invalid provider "${providerName}". Valid providers: ${availableProviders}` );
95
109
  }
96
110
 
97
111
  if ( !provider.tools || typeof provider.tools !== 'object' ) {
98
- throw new Error(
99
- `Provider "${providerName}" does not support provider-specific tools.`
100
- );
112
+ throw new Error( `Provider "${providerName}" does not support provider-specific tools.` );
101
113
  }
102
114
 
103
115
  const tools = {};
@@ -109,18 +121,14 @@ export function loadTools( prompt ) {
109
121
  const availableTools = Object.keys( provider.tools )
110
122
  .filter( key => typeof provider.tools[key] === 'function' )
111
123
  .join( ', ' );
124
+ const toolsMessage = availableTools ? `Available tools: ${availableTools}` : 'No tools are available';
112
125
 
113
- throw new Error(
114
- `Unknown tool "${toolName}" for provider "${providerName}".` +
115
- ( availableTools ? ` Available tools: ${availableTools}` : '' )
116
- );
126
+ throw new Error( `Unknown tool "${toolName}" for provider "${providerName}". ${toolsMessage}` );
117
127
  }
118
128
 
119
- if ( typeof toolConfig !== 'object' || toolConfig === null ) {
120
- throw new Error(
121
- `Configuration for tool "${toolName}" must be an object. ` +
122
- `Use "${toolName}: {}" for tools without configuration.`
123
- );
129
+ const result = toolConfigSchema.safeParse( toolConfig );
130
+ if ( !result.success ) {
131
+ throw new ValidationError( `Invalid config for tool "${toolName}": ${z.prettifyError( result.error )}` );
124
132
  }
125
133
 
126
134
  tools[toolName] = toolFactory( toolConfig );
@@ -1,10 +1,12 @@
1
1
  import { it, expect, vi, afterEach, describe } from 'vitest';
2
2
 
3
- const openaiImpl = vi.fn( model => `openai:${model}` );
4
- const azureImpl = vi.fn( model => `azure:${model}` );
5
- const anthropicImpl = vi.fn( model => `anthropic:${model}` );
6
- const bedrockImpl = vi.fn( model => `bedrock:${model}` );
7
- const perplexityImpl = vi.fn( model => `perplexity:${model}` );
3
+ const providerFactoryOptions = vi.hoisted( () => ( {} ) );
4
+ const openaiImpl = vi.hoisted( () => vi.fn( model => `openai:${model}` ) );
5
+ const azureImpl = vi.hoisted( () => vi.fn( model => `azure:${model}` ) );
6
+ const anthropicImpl = vi.hoisted( () => vi.fn( model => `anthropic:${model}` ) );
7
+ const bedrockImpl = vi.hoisted( () => vi.fn( model => `bedrock:${model}` ) );
8
+ const perplexityImpl = vi.hoisted( () => vi.fn( model => `perplexity:${model}` ) );
9
+ const vertexImpl = vi.hoisted( () => vi.fn( model => `vertex:${model}` ) );
8
10
 
9
11
  // OpenAI mock with tools support
10
12
  vi.mock( '@ai-sdk/openai', () => {
@@ -12,12 +14,20 @@ vi.mock( '@ai-sdk/openai', () => {
12
14
  openaiMock.tools = {
13
15
  webSearch: ( config = {} ) => ( { type: 'webSearch', config } )
14
16
  };
15
- return { openai: openaiMock };
17
+ return {
18
+ createOpenAI: options => {
19
+ providerFactoryOptions.openai = options;
20
+ return openaiMock;
21
+ }
22
+ };
16
23
  } );
17
24
 
18
25
  // Azure mock without tools support
19
26
  vi.mock( '@ai-sdk/azure', () => ( {
20
- azure: ( ...values ) => azureImpl( ...values )
27
+ createAzure: options => {
28
+ providerFactoryOptions.azure = options;
29
+ return ( ...values ) => azureImpl( ...values );
30
+ }
21
31
  } ) );
22
32
 
23
33
  // Anthropic mock with tools support
@@ -30,7 +40,12 @@ vi.mock( '@ai-sdk/anthropic', () => {
30
40
  codeExecution_20250522: ( config = {} ) => ( { type: 'codeExecution_20250522', config } ),
31
41
  codeExecution_20250825: ( config = {} ) => ( { type: 'codeExecution_20250825', config } )
32
42
  };
33
- return { anthropic: anthropicMock };
43
+ return {
44
+ createAnthropic: options => {
45
+ providerFactoryOptions.anthropic = options;
46
+ return anthropicMock;
47
+ }
48
+ };
34
49
  } );
35
50
 
36
51
  // Bedrock mock with tools support
@@ -42,17 +57,25 @@ vi.mock( '@ai-sdk/amazon-bedrock', () => {
42
57
  textEditor_20250429: ( config = {} ) => ( { type: 'textEditor_20250429', config } ),
43
58
  computer_20241022: ( config = {} ) => ( { type: 'computer_20241022', config } )
44
59
  };
45
- return { bedrock: bedrockMock };
60
+ return {
61
+ createAmazonBedrock: options => {
62
+ providerFactoryOptions.bedrock = options;
63
+ return bedrockMock;
64
+ }
65
+ };
46
66
  } );
47
67
 
48
68
  // Perplexity mock
49
69
  vi.mock( '@ai-sdk/perplexity', () => ( {
50
- perplexity: ( ...values ) => perplexityImpl( ...values )
70
+ createPerplexity: options => {
71
+ providerFactoryOptions.perplexity = options;
72
+ return ( ...values ) => perplexityImpl( ...values );
73
+ }
51
74
  } ) );
52
75
 
53
76
  // Vertex mock with tools support
54
77
  vi.mock( '@ai-sdk/google-vertex', () => {
55
- const vertexFn = model => `vertex:${model}`;
78
+ const vertexFn = ( ...values ) => vertexImpl( ...values );
56
79
  vertexFn.tools = {
57
80
  googleSearch: ( config = {} ) => ( { type: 'googleSearch', config } ),
58
81
  fileSearch: ( config = {} ) => ( { type: 'fileSearch', config } ),
@@ -62,7 +85,12 @@ vi.mock( '@ai-sdk/google-vertex', () => {
62
85
  codeExecution: ( config = {} ) => ( { type: 'codeExecution', config } ),
63
86
  vertexRagStore: ( config = {} ) => ( { type: 'vertexRagStore', config } )
64
87
  };
65
- return { vertex: vertexFn };
88
+ return {
89
+ createVertex: options => {
90
+ providerFactoryOptions.vertex = options;
91
+ return vertexFn;
92
+ }
93
+ };
66
94
  } );
67
95
 
68
96
  import { loadModel, loadTools, registerProvider, getRegisteredProviders, providers, builtInProviders } from './ai_model.js';
@@ -73,6 +101,17 @@ afterEach( async () => {
73
101
  } );
74
102
 
75
103
  describe( 'loadModel', () => {
104
+ it( 'initializes built-in providers with custom fetch', () => {
105
+ expect( providerFactoryOptions ).toMatchObject( {
106
+ azure: { fetch: expect.any( Function ) },
107
+ anthropic: { fetch: expect.any( Function ) },
108
+ openai: { fetch: expect.any( Function ) },
109
+ vertex: { fetch: expect.any( Function ) },
110
+ bedrock: { fetch: expect.any( Function ) },
111
+ perplexity: { fetch: expect.any( Function ) }
112
+ } );
113
+ } );
114
+
76
115
  it( 'loads model using selected provider', () => {
77
116
  const result = loadModel( { config: { provider: 'openai', model: 'gpt-4o-mini' } } );
78
117
 
@@ -617,7 +656,7 @@ describe( 'loadTools', () => {
617
656
  provider: 'vertex',
618
657
  tools: { googleSearch: null }
619
658
  }
620
- } ) ).toThrow( 'Configuration for tool "googleSearch" must be an object' );
659
+ } ) ).toThrow( /Invalid config for tool "googleSearch".*expected record, received null/s );
621
660
  } );
622
661
 
623
662
  it( 'throws error when tool config is a string', () => {
@@ -626,7 +665,7 @@ describe( 'loadTools', () => {
626
665
  provider: 'vertex',
627
666
  tools: { googleSearch: 'MODE_DYNAMIC' }
628
667
  }
629
- } ) ).toThrow( 'Configuration for tool "googleSearch" must be an object' );
668
+ } ) ).toThrow( /Invalid config for tool "googleSearch".*expected record, received string/s );
630
669
  } );
631
670
 
632
671
  it( 'throws error for unknown tool on Bedrock with dynamic tool listing', () => {
package/src/ai_sdk.js CHANGED
@@ -64,10 +64,10 @@ export const hydratePromptTemplate = ( prompt, variables, promptDir, callerSkill
64
64
  return { loadedPrompt: meta, allVariables: variables, tools };
65
65
  };
66
66
 
67
- export async function generateText( { prompt, variables, promptDir, skills = [], maxSteps = 10, ...extraAiSdkOptions } ) {
67
+ export async function generateText( { prompt, variables, promptDir, skills = [], maxSteps = 10, ...aiSdkArgs } ) {
68
68
  const callerSkills = typeof skills === 'function' ? await skills( variables ) : skills;
69
69
  const { loadedPrompt, allVariables, tools } =
70
- hydratePromptTemplate( prompt, variables, promptDir, callerSkills, extraAiSdkOptions.tools );
70
+ hydratePromptTemplate( prompt, variables, promptDir, callerSkills, aiSdkArgs.tools );
71
71
  const hasTools = Object.keys( tools ).length > 0;
72
72
 
73
73
  validateGenerateTextArgs( { prompt, variables: allVariables } );
@@ -78,9 +78,10 @@ export async function generateText( { prompt, variables, promptDir, skills = [],
78
78
  try {
79
79
  const response = await AI.generateText( {
80
80
  ...loadAiSdkOptionsFromPrompt( loadedPrompt ),
81
- ...extraAiSdkOptions,
81
+ maxRetries: 0,
82
+ ...aiSdkArgs,
82
83
  ...( hasTools ? { tools } : {} ),
83
- ...( hasTools && !extraAiSdkOptions.stopWhen ? { stopWhen: stepCountIs( maxSteps ) } : {} )
84
+ ...( hasTools && !aiSdkArgs.stopWhen ? { stopWhen: stepCountIs( maxSteps ) } : {} )
84
85
  } );
85
86
  return wrapTextResponse( { traceId, modelId, response } );
86
87
  } catch ( error ) {
@@ -89,7 +90,7 @@ export async function generateText( { prompt, variables, promptDir, skills = [],
89
90
  }
90
91
  }
91
92
 
92
- export function streamText( { prompt, variables, onFinish, onError, ...restOptions } ) {
93
+ export function streamText( { prompt, variables, onFinish, onError, ...aiSdkArgs } ) {
93
94
  validateStreamTextArgs( { prompt, variables } );
94
95
  const loadedPrompt = loadPrompt( prompt, variables );
95
96
  const traceId = startTrace( { name: 'streamText', prompt, variables, loadedPrompt } );
@@ -98,7 +99,8 @@ export function streamText( { prompt, variables, onFinish, onError, ...restOptio
98
99
  try {
99
100
  return AI.streamText( {
100
101
  ...loadAiSdkOptionsFromPrompt( loadedPrompt ),
101
- ...restOptions,
102
+ maxRetries: 0,
103
+ ...aiSdkArgs,
102
104
  ...wrapStreamOnFinishResponse( { traceId, modelId, onFinish } ),
103
105
  onError( event ) {
104
106
  endTraceWithError( { traceId, error: event.error } );
@@ -149,6 +149,7 @@ describe( 'ai_sdk', () => {
149
149
  model: 'MODEL',
150
150
  messages: basePrompt.messages,
151
151
  temperature: 0.3,
152
+ maxRetries: 0,
152
153
  providerOptions: basePrompt.config.providerOptions
153
154
  } );
154
155
  expect( result ).toBe( generateTextAiFixture.response );
@@ -182,6 +183,7 @@ describe( 'ai_sdk', () => {
182
183
  expect( aiFns.generateText ).toHaveBeenCalledWith( {
183
184
  model: 'MODEL',
184
185
  messages: promptWithProviderOptions.messages,
186
+ maxRetries: 0,
185
187
  providerOptions: {
186
188
  thinking: {
187
189
  type: 'enabled',
@@ -362,6 +364,7 @@ describe( 'ai_sdk', () => {
362
364
  model: 'MODEL',
363
365
  messages: basePrompt.messages,
364
366
  temperature: 0.3,
367
+ maxRetries: 0,
365
368
  providerOptions: basePrompt.config.providerOptions,
366
369
  onFinish: expect.any( Function ),
367
370
  onError: expect.any( Function )
package/src/cost/index.js CHANGED
@@ -1,8 +1,5 @@
1
1
  import { fetchModelsPricing } from './fetch_models_pricing.js';
2
- import Decimal from 'decimal.js';
3
-
4
- const M = 1_000_000;
5
- const calcCost = ( tokens, ppm ) => Decimal( tokens ?? 0 ).div( M ).mul( ppm ).toNumber();
2
+ import { Tracing } from '@outputai/core/sdk_activity_integration';
6
3
 
7
4
  /**
8
5
  * Calculates the cost of an llm call based on the model and usage.
@@ -14,29 +11,41 @@ const calcCost = ( tokens, ppm ) => Decimal( tokens ?? 0 ).div( M ).mul( ppm ).t
14
11
  export const calculateLLMCallCost = async ( { modelId, usage } ) => {
15
12
  try {
16
13
  const models = await fetchModelsPricing();
14
+
17
15
  if ( !models ) {
18
- return { total: null, message: 'Failed to fetch models pricing' };
16
+ console.warn( 'Failed to fetch models pricing' );
17
+ return null;
19
18
  }
20
19
 
21
- const cost = models.get( modelId );
22
- if ( !cost ) {
23
- return { total: null, message: 'Missing cost reference for model' };
20
+ const pricing = models.get( modelId );
21
+ if ( !pricing ) {
22
+ console.warn( 'Missing cost reference for model' );
23
+ return null;
24
24
  }
25
25
 
26
26
  const { inputTokens, cachedInputTokens, outputTokens, reasoningTokens } = usage;
27
27
 
28
28
  const nonCachedTokens = inputTokens - ( cachedInputTokens ?? 0 );
29
29
 
30
- const components = [
31
- Number.isFinite( cost.input ) ? { name: 'input_tokens', value: calcCost( nonCachedTokens, cost.input ) } : false,
32
- Number.isFinite( cost.cache_read ) ? { name: 'input_cached_tokens', value: calcCost( cachedInputTokens, cost.cache_read ) } : false,
33
- Number.isFinite( cost.output ) ? { name: 'output_tokens', value: calcCost( outputTokens, cost.output ) } : false,
34
- /* When there aren't reasoning costs, the providers doesn't differentiate reasoning vs output, so the price is included in the output */
35
- Number.isFinite( cost.reasoning ) ? { name: 'reasoning_tokens', value: calcCost( reasoningTokens, cost.reasoning ) } : false
36
- ].filter( v => !!v );
37
- return { total: components.reduce( ( v, e ) => v.plus( e.value ), Decimal( 0 ) ).toNumber(), components };
30
+ const llmUsage = new Tracing.Attribute.LLMUsage( modelId );
31
+
32
+ if ( Number.isFinite( pricing.input ) && Number.isFinite( nonCachedTokens ) ) {
33
+ llmUsage.addUsage( { type: 'input', ppm: pricing.input, amount: nonCachedTokens } );
34
+ }
35
+ if ( Number.isFinite( pricing.cache_read ) && Number.isFinite( cachedInputTokens ) ) {
36
+ llmUsage.addUsage( { type: 'input_cached', ppm: pricing.cache_read, amount: cachedInputTokens } );
37
+ }
38
+ if ( Number.isFinite( pricing.output ) && Number.isFinite( outputTokens ) ) {
39
+ llmUsage.addUsage( { type: 'output', ppm: pricing.output, amount: outputTokens } );
40
+ }
41
+ // When there aren't reasoning costs, the providers doesn't differentiate reasoning vs output, so the price is included in the output
42
+ if ( Number.isFinite( pricing.reasoning ) && Number.isFinite( reasoningTokens ) ) {
43
+ llmUsage.addUsage( { type: 'reasoning', ppm: pricing.reasoning, amount: reasoningTokens } );
44
+ }
45
+
46
+ return llmUsage;
38
47
  } catch ( error ) {
39
48
  console.error( 'Error calculating LLM call costs', error );
40
- return { total: null, message: `Error calculating LLM call costs: ${error.constructor.name} - ${error.message}` };
49
+ return null;
41
50
  }
42
51
  };
@@ -1,18 +1,75 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ const mockFetchModelsPricing = vi.hoisted( () => vi.fn() );
2
4
 
3
- const mockFetchModelsPricing = vi.fn();
4
5
  vi.mock( './fetch_models_pricing.js', () => ( {
5
6
  fetchModelsPricing: ( ...args ) => mockFetchModelsPricing( ...args )
6
7
  } ) );
7
8
 
9
+ vi.mock( '@outputai/core/sdk_activity_integration', () => {
10
+ class LLMUsage {
11
+ static TYPE = 'llm:usage';
12
+ type = LLMUsage.TYPE;
13
+ modelId;
14
+ usage = [];
15
+
16
+ constructor( modelId ) {
17
+ this.modelId = modelId;
18
+ }
19
+
20
+ addUsage( { type, ppm, amount } ) {
21
+ this.usage.push( {
22
+ type,
23
+ ppm,
24
+ amount,
25
+ total: ( amount / 1_000_000 ) * ppm
26
+ } );
27
+ }
28
+
29
+ get total() {
30
+ return this.usage.reduce( ( total, current ) => total + current.total, 0 );
31
+ }
32
+
33
+ get tokensUsed() {
34
+ return this.usage.reduce( ( total, current ) => total + current.amount, 0 );
35
+ }
36
+ }
37
+
38
+ return {
39
+ Tracing: {
40
+ Attribute: {
41
+ LLMUsage
42
+ }
43
+ }
44
+ };
45
+ } );
46
+
47
+ import { Tracing } from '@outputai/core/sdk_activity_integration';
8
48
  import { calculateLLMCallCost } from './index.js';
9
49
 
50
+ const expectLLMUsage = ( result, { modelId, usage, total, tokensUsed } ) => {
51
+ expect( result ).toBeInstanceOf( Tracing.Attribute.LLMUsage );
52
+ expect( result ).toEqual( expect.objectContaining( {
53
+ type: Tracing.Attribute.LLMUsage.TYPE,
54
+ modelId,
55
+ usage
56
+ } ) );
57
+ expect( result.total ).toBeCloseTo( total );
58
+ expect( result.tokensUsed ).toBe( tokensUsed );
59
+ };
60
+
10
61
  describe( 'calculateLLMCallCost', () => {
11
62
  beforeEach( () => {
12
63
  vi.clearAllMocks();
64
+ vi.spyOn( console, 'warn' ).mockImplementation( () => {} );
65
+ vi.spyOn( console, 'error' ).mockImplementation( () => {} );
13
66
  } );
14
67
 
15
- it( 'returns total null and message when fetchModelsPricing returns null', async () => {
68
+ afterEach( () => {
69
+ vi.restoreAllMocks();
70
+ } );
71
+
72
+ it( 'returns null when fetchModelsPricing returns null', async () => {
16
73
  mockFetchModelsPricing.mockResolvedValue( null );
17
74
 
18
75
  const result = await calculateLLMCallCost( {
@@ -20,10 +77,11 @@ describe( 'calculateLLMCallCost', () => {
20
77
  usage: { inputTokens: 100, outputTokens: 50 }
21
78
  } );
22
79
 
23
- expect( result ).toEqual( { total: null, message: 'Failed to fetch models pricing' } );
80
+ expect( result ).toBeNull();
81
+ expect( console.warn ).toHaveBeenCalledWith( 'Failed to fetch models pricing' );
24
82
  } );
25
83
 
26
- it( 'returns total null and message when model is missing from cost table', async () => {
84
+ it( 'returns null when model is missing from cost table', async () => {
27
85
  mockFetchModelsPricing.mockResolvedValue( new Map() );
28
86
 
29
87
  const result = await calculateLLMCallCost( {
@@ -31,47 +89,50 @@ describe( 'calculateLLMCallCost', () => {
31
89
  usage: { inputTokens: 100, outputTokens: 50 }
32
90
  } );
33
91
 
34
- expect( result ).toEqual( {
35
- total: null,
36
- message: 'Missing cost reference for model'
37
- } );
92
+ expect( result ).toBeNull();
93
+ expect( console.warn ).toHaveBeenCalledWith( 'Missing cost reference for model' );
38
94
  } );
39
95
 
40
- it( 'calculates input and output cost from mock model', async () => {
41
- const cost = { input: 2, output: 10, cache_read: 1 };
42
- mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'gpt-4o', cost ] ] ) );
96
+ it( 'calculates input and output usage from model pricing', async () => {
97
+ mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'gpt-4o', { input: 2, output: 10, cache_read: 1 } ] ] ) );
43
98
 
44
99
  const result = await calculateLLMCallCost( {
45
100
  modelId: 'gpt-4o',
46
101
  usage: { inputTokens: 1_000_000, outputTokens: 500_000 }
47
102
  } );
48
103
 
49
- expect( result.total ).toBe( 7 );
50
- expect( result.components ).toEqual( [
51
- { name: 'input_tokens', value: 2 },
52
- { name: 'input_cached_tokens', value: 0 },
53
- { name: 'output_tokens', value: 5 }
54
- ] );
104
+ expectLLMUsage( result, {
105
+ modelId: 'gpt-4o',
106
+ usage: [
107
+ { type: 'input', ppm: 2, amount: 1_000_000, total: 2 },
108
+ { type: 'output', ppm: 10, amount: 500_000, total: 5 }
109
+ ],
110
+ total: 7,
111
+ tokensUsed: 1_500_000
112
+ } );
55
113
  } );
56
114
 
57
- it( 'splits input into non-cached and cached at respective rates', async () => {
58
- const cost = { input: 4, cache_read: 1, output: 10 };
59
- mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'cached-model', cost ] ] ) );
115
+ it( 'splits input into non-cached and cached usage at respective rates', async () => {
116
+ mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'cached-model', { input: 4, cache_read: 1, output: 10 } ] ] ) );
60
117
 
61
118
  const result = await calculateLLMCallCost( {
62
119
  modelId: 'cached-model',
63
120
  usage: { inputTokens: 1_000_000, cachedInputTokens: 500_000, outputTokens: 100_000 }
64
121
  } );
65
122
 
66
- expect( result.components ).toEqual( [
67
- { name: 'input_tokens', value: 2 },
68
- { name: 'input_cached_tokens', value: 0.5 },
69
- { name: 'output_tokens', value: 1 }
70
- ] );
71
- expect( result.total ).toBeCloseTo( 3.5 );
123
+ expectLLMUsage( result, {
124
+ modelId: 'cached-model',
125
+ usage: [
126
+ { type: 'input', ppm: 4, amount: 500_000, total: 2 },
127
+ { type: 'input_cached', ppm: 1, amount: 500_000, total: 0.5 },
128
+ { type: 'output', ppm: 10, amount: 100_000, total: 1 }
129
+ ],
130
+ total: 3.5,
131
+ tokensUsed: 1_100_000
132
+ } );
72
133
  } );
73
134
 
74
- it( 'omits cached component when model has no cache_read (non-cached rate applies to full input minus cached)', async () => {
135
+ it( 'omits cached usage when model has no cache_read rate', async () => {
75
136
  mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'no-cache', { input: 2, output: 10 } ] ] ) );
76
137
 
77
138
  const result = await calculateLLMCallCost( {
@@ -79,14 +140,18 @@ describe( 'calculateLLMCallCost', () => {
79
140
  usage: { inputTokens: 1_000_000, cachedInputTokens: 200_000, outputTokens: 0 }
80
141
  } );
81
142
 
82
- expect( result.components ).toEqual( [
83
- { name: 'input_tokens', value: 1.6 },
84
- { name: 'output_tokens', value: 0 }
85
- ] );
86
- expect( result.total ).toBe( 1.6 );
143
+ expectLLMUsage( result, {
144
+ modelId: 'no-cache',
145
+ usage: [
146
+ { type: 'input', ppm: 2, amount: 800_000, total: 1.6 },
147
+ { type: 'output', ppm: 10, amount: 0, total: 0 }
148
+ ],
149
+ total: 1.6,
150
+ tokensUsed: 800_000
151
+ } );
87
152
  } );
88
153
 
89
- it( 'omits input component when pricing has no input rate', async () => {
154
+ it( 'omits input usage when pricing has no input rate', async () => {
90
155
  mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'out-only', { output: 10 } ] ] ) );
91
156
 
92
157
  const result = await calculateLLMCallCost( {
@@ -94,13 +159,17 @@ describe( 'calculateLLMCallCost', () => {
94
159
  usage: { inputTokens: 100, outputTokens: 50 }
95
160
  } );
96
161
 
97
- expect( result.total ).toBe( 0.0005 );
98
- expect( result.components ).toEqual( [
99
- { name: 'output_tokens', value: 0.0005 }
100
- ] );
162
+ expectLLMUsage( result, {
163
+ modelId: 'out-only',
164
+ usage: [
165
+ { type: 'output', ppm: 10, amount: 50, total: 0.0005 }
166
+ ],
167
+ total: 0.0005,
168
+ tokensUsed: 50
169
+ } );
101
170
  } );
102
171
 
103
- it( 'omits output component when pricing has no output rate', async () => {
172
+ it( 'omits output usage when pricing has no output rate', async () => {
104
173
  mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'in-only', { input: 1 } ] ] ) );
105
174
 
106
175
  const result = await calculateLLMCallCost( {
@@ -108,13 +177,17 @@ describe( 'calculateLLMCallCost', () => {
108
177
  usage: { inputTokens: 100, outputTokens: 50 }
109
178
  } );
110
179
 
111
- expect( result.total ).toBe( 0.0001 );
112
- expect( result.components ).toEqual( [
113
- { name: 'input_tokens', value: 0.0001 }
114
- ] );
180
+ expectLLMUsage( result, {
181
+ modelId: 'in-only',
182
+ usage: [
183
+ { type: 'input', ppm: 1, amount: 100, total: 0.0001 }
184
+ ],
185
+ total: 0.0001,
186
+ tokensUsed: 100
187
+ } );
115
188
  } );
116
189
 
117
- it( 'uses reasoning cost when present', async () => {
190
+ it( 'includes reasoning usage when present', async () => {
118
191
  mockFetchModelsPricing.mockResolvedValue( new Map( [ [
119
192
  'with-reasoning',
120
193
  { input: 1, output: 10, reasoning: 60 }
@@ -125,15 +198,19 @@ describe( 'calculateLLMCallCost', () => {
125
198
  usage: { inputTokens: 100, outputTokens: 20, reasoningTokens: 50 }
126
199
  } );
127
200
 
128
- expect( result.total ).toBeCloseTo( 0.0033 );
129
- expect( result.components ).toEqual( [
130
- { name: 'input_tokens', value: 0.0001 },
131
- { name: 'output_tokens', value: 0.0002 },
132
- { name: 'reasoning_tokens', value: 0.003 }
133
- ] );
201
+ expectLLMUsage( result, {
202
+ modelId: 'with-reasoning',
203
+ usage: [
204
+ { type: 'input', ppm: 1, amount: 100, total: 0.0001 },
205
+ { type: 'output', ppm: 10, amount: 20, total: 0.0002 },
206
+ { type: 'reasoning', ppm: 60, amount: 50, total: 0.003 }
207
+ ],
208
+ total: 0.0033,
209
+ tokensUsed: 170
210
+ } );
134
211
  } );
135
212
 
136
- it( 'omits reasoning component when reasoning cost missing (included in output)', async () => {
213
+ it( 'omits reasoning usage when reasoning cost is missing', async () => {
137
214
  mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'no-reasoning', { input: 1, output: 10 } ] ] ) );
138
215
 
139
216
  const result = await calculateLLMCallCost( {
@@ -141,14 +218,18 @@ describe( 'calculateLLMCallCost', () => {
141
218
  usage: { inputTokens: 100, outputTokens: 20, reasoningTokens: 50 }
142
219
  } );
143
220
 
144
- expect( result.total ).toBeCloseTo( 0.0003 );
145
- expect( result.components ).toEqual( [
146
- { name: 'input_tokens', value: 0.0001 },
147
- { name: 'output_tokens', value: 0.0002 }
148
- ] );
221
+ expectLLMUsage( result, {
222
+ modelId: 'no-reasoning',
223
+ usage: [
224
+ { type: 'input', ppm: 1, amount: 100, total: 0.0001 },
225
+ { type: 'output', ppm: 10, amount: 20, total: 0.0002 }
226
+ ],
227
+ total: 0.0003,
228
+ tokensUsed: 120
229
+ } );
149
230
  } );
150
231
 
151
- it( 'includes reasoning component with zero when reasoningTokens is zero', async () => {
232
+ it( 'includes reasoning usage with zero amount when reasoningTokens is zero', async () => {
152
233
  mockFetchModelsPricing.mockResolvedValue( new Map( [ [
153
234
  'full',
154
235
  { input: 2, output: 8, reasoning: 60 }
@@ -159,15 +240,19 @@ describe( 'calculateLLMCallCost', () => {
159
240
  usage: { inputTokens: 100, outputTokens: 50, reasoningTokens: 0 }
160
241
  } );
161
242
 
162
- expect( result.components ).toEqual( [
163
- { name: 'input_tokens', value: 0.0002 },
164
- { name: 'output_tokens', value: 0.0004 },
165
- { name: 'reasoning_tokens', value: 0 }
166
- ] );
167
- expect( result.total ).toBeCloseTo( 0.0006 );
243
+ expectLLMUsage( result, {
244
+ modelId: 'full',
245
+ usage: [
246
+ { type: 'input', ppm: 2, amount: 100, total: 0.0002 },
247
+ { type: 'output', ppm: 8, amount: 50, total: 0.0004 },
248
+ { type: 'reasoning', ppm: 60, amount: 0, total: 0 }
249
+ ],
250
+ total: 0.0006,
251
+ tokensUsed: 150
252
+ } );
168
253
  } );
169
254
 
170
- it( 'treats null/undefined token counts as 0', async () => {
255
+ it( 'omits usage entries for non-finite token counts', async () => {
171
256
  mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'm', { input: 1, output: 2 } ] ] ) );
172
257
 
173
258
  const result = await calculateLLMCallCost( {
@@ -175,10 +260,26 @@ describe( 'calculateLLMCallCost', () => {
175
260
  usage: { inputTokens: null, outputTokens: undefined }
176
261
  } );
177
262
 
178
- expect( result.total ).toBe( 0 );
179
- expect( result.components ).toEqual( [
180
- { name: 'input_tokens', value: 0 },
181
- { name: 'output_tokens', value: 0 }
182
- ] );
263
+ expectLLMUsage( result, {
264
+ modelId: 'm',
265
+ usage: [
266
+ { type: 'input', ppm: 1, amount: 0, total: 0 }
267
+ ],
268
+ total: 0,
269
+ tokensUsed: 0
270
+ } );
271
+ } );
272
+
273
+ it( 'returns null when pricing lookup throws', async () => {
274
+ const error = new Error( 'boom' );
275
+ mockFetchModelsPricing.mockRejectedValue( error );
276
+
277
+ const result = await calculateLLMCallCost( {
278
+ modelId: 'gpt-4o',
279
+ usage: { inputTokens: 100, outputTokens: 50 }
280
+ } );
281
+
282
+ expect( result ).toBeNull();
283
+ expect( console.error ).toHaveBeenCalledWith( 'Error calculating LLM call costs', error );
183
284
  } );
184
285
  } );
@@ -12,7 +12,9 @@ export const endTraceWithError = ( { traceId, error } ) => {
12
12
 
13
13
  export const endTraceWithSuccess = ( { traceId, modelId, response, cost, ...extra } ) => {
14
14
  const { totalUsage: usage, text: result, providerMetadata } = response;
15
- Tracing.addEventAttribute( { eventId: traceId, name: Tracing.Attribute.COST, value: cost } );
15
+ if ( cost ) {
16
+ Tracing.addEventAttribute( { eventId: traceId, attribute: cost } );
17
+ emitEvent( 'cost:llm:request', cost );
18
+ }
16
19
  Tracing.addEventEnd( { id: traceId, details: { result, usage, providerMetadata, ...extra } } );
17
- emitEvent( 'cost:llm:request', { modelId, cost, usage } );
18
20
  };
@@ -5,10 +5,7 @@ vi.mock( '@outputai/core/sdk_activity_integration', () => ( {
5
5
  addEventStart: vi.fn(),
6
6
  addEventError: vi.fn(),
7
7
  addEventAttribute: vi.fn(),
8
- addEventEnd: vi.fn(),
9
- Attribute: {
10
- COST: 'cost'
11
- }
8
+ addEventEnd: vi.fn()
12
9
  },
13
10
  emitEvent: vi.fn()
14
11
  } ) );
@@ -54,8 +51,8 @@ describe( 'trace utils', () => {
54
51
  } );
55
52
 
56
53
  describe( 'endTraceWithSuccess', () => {
57
- it( 'adds cost attribute, ends the trace with response fields and extra details, and emits cost:llm:request', () => {
58
- const cost = { total: 0.01, components: [] };
54
+ it( 'adds cost attribute, emits cost attribute, and ends the trace with response fields and extra details', () => {
55
+ const cost = { type: 'llm:usage', modelId: 'my-model', total: 0.01, usage: [] };
59
56
  const usage = { inputTokens: 2, outputTokens: 3 };
60
57
  const response = {
61
58
  text: 'hello',
@@ -73,9 +70,9 @@ describe( 'trace utils', () => {
73
70
 
74
71
  expect( tracing.addEventAttribute ).toHaveBeenCalledWith( {
75
72
  eventId: 'trace-a',
76
- name: 'cost',
77
- value: cost
73
+ attribute: cost
78
74
  } );
75
+ expect( emitEvent ).toHaveBeenCalledWith( 'cost:llm:request', cost );
79
76
  expect( tracing.addEventEnd ).toHaveBeenCalledWith( {
80
77
  id: 'trace-a',
81
78
  details: {
@@ -85,10 +82,31 @@ describe( 'trace utils', () => {
85
82
  sourcesFromTools: [ { url: 'https://u.test', title: '' } ]
86
83
  }
87
84
  } );
88
- expect( emitEvent ).toHaveBeenCalledWith( 'cost:llm:request', {
85
+ } );
86
+
87
+ it( 'does not emit or add an attribute when cost is missing', () => {
88
+ const usage = { inputTokens: 2, outputTokens: 3 };
89
+ const response = {
90
+ text: 'hello',
91
+ totalUsage: usage,
92
+ providerMetadata: { provider: 'x' }
93
+ };
94
+
95
+ endTraceWithSuccess( {
96
+ traceId: 'trace-no-cost',
89
97
  modelId: 'my-model',
90
- cost,
91
- usage
98
+ response
99
+ } );
100
+
101
+ expect( tracing.addEventAttribute ).not.toHaveBeenCalled();
102
+ expect( emitEvent ).not.toHaveBeenCalled();
103
+ expect( tracing.addEventEnd ).toHaveBeenCalledWith( {
104
+ id: 'trace-no-cost',
105
+ details: {
106
+ result: 'hello',
107
+ usage,
108
+ providerMetadata: { provider: 'x' }
109
+ }
92
110
  } );
93
111
  } );
94
112
  } );