@outputai/llm 0.4.1-next.ae3ab85.0 → 0.4.1-next.d085dde.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 +3 -3
- package/src/ai_model.js +44 -36
- package/src/ai_model.spec.js +53 -14
- package/src/ai_sdk.js +8 -6
- package/src/ai_sdk.spec.js +3 -0
- package/src/cost/index.js +26 -17
- package/src/cost/index.spec.js +170 -69
- package/src/utils/trace.js +4 -2
- package/src/utils/trace.spec.js +29 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/llm",
|
|
3
|
-
"version": "0.4.1-next.
|
|
3
|
+
"version": "0.4.1-next.d085dde.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
|
-
"
|
|
25
|
+
"undici": "8.1.0",
|
|
26
|
+
"@outputai/core": "0.4.1-next.d085dde.0"
|
|
27
27
|
},
|
|
28
28
|
"license": "Apache-2.0",
|
|
29
29
|
"publishConfig": {
|
package/src/ai_model.js
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 );
|
package/src/ai_model.spec.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { it, expect, vi, afterEach, describe } from 'vitest';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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(
|
|
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(
|
|
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, ...
|
|
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,
|
|
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
|
-
|
|
81
|
+
maxRetries: 0,
|
|
82
|
+
...aiSdkArgs,
|
|
82
83
|
...( hasTools ? { tools } : {} ),
|
|
83
|
-
...( hasTools && !
|
|
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, ...
|
|
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
|
-
|
|
102
|
+
maxRetries: 0,
|
|
103
|
+
...aiSdkArgs,
|
|
102
104
|
...wrapStreamOnFinishResponse( { traceId, modelId, onFinish } ),
|
|
103
105
|
onError( event ) {
|
|
104
106
|
endTraceWithError( { traceId, error: event.error } );
|
package/src/ai_sdk.spec.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
16
|
+
console.warn( 'Failed to fetch models pricing' );
|
|
17
|
+
return null;
|
|
19
18
|
}
|
|
20
19
|
|
|
21
|
-
const
|
|
22
|
-
if ( !
|
|
23
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
49
|
+
return null;
|
|
41
50
|
}
|
|
42
51
|
};
|
package/src/cost/index.spec.js
CHANGED
|
@@ -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
|
-
|
|
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 ).
|
|
80
|
+
expect( result ).toBeNull();
|
|
81
|
+
expect( console.warn ).toHaveBeenCalledWith( 'Failed to fetch models pricing' );
|
|
24
82
|
} );
|
|
25
83
|
|
|
26
|
-
it( 'returns
|
|
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 ).
|
|
35
|
-
|
|
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
|
|
41
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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( '
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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( '
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
} );
|
package/src/utils/trace.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/src/utils/trace.spec.js
CHANGED
|
@@ -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
|
|
58
|
-
const cost = { total: 0.01,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
} );
|