@outputai/llm 0.10.1-next.fc0a41f.0 → 0.11.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 +5 -5
- package/src/agent.js +4 -2
- package/src/agent.spec.js +3 -1
- package/src/ai_provider.js +10 -3
- package/src/ai_provider.spec.js +16 -5
- package/src/ai_sdk.js +6 -6
- package/src/ai_sdk.spec.js +4 -1
- package/src/cost/fetch_models_pricing.js +0 -1
- package/src/cost/fetch_models_pricing.spec.js +18 -5
- package/src/cost/fixtures/models_api_light.json +14 -0
- package/src/cost/index.js +5 -4
- package/src/cost/index.spec.js +82 -15
- package/src/deprecated_provider_aliases.js +8 -0
- package/src/index.d.ts +7 -1
- package/src/prompt/loader.js +9 -1
- package/src/prompt/loader.spec.js +87 -0
- package/src/utils/error_handler.js +26 -106
- package/src/utils/error_handler.spec.js +11 -91
- package/src/utils/response_wrappers.js +9 -6
- package/src/utils/response_wrappers.spec.js +15 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/llm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Framework abstraction to interact with LLM models",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -14,12 +14,12 @@
|
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"entities": "8.0.0",
|
|
16
16
|
"gray-matter": "4.0.3",
|
|
17
|
-
"liquidjs": "10.
|
|
18
|
-
"undici": "8.
|
|
19
|
-
"@outputai/core": "0.
|
|
17
|
+
"liquidjs": "10.27.2",
|
|
18
|
+
"undici": "8.9.0",
|
|
19
|
+
"@outputai/core": "0.11.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@ai-sdk/amazon-bedrock": "4.0.
|
|
22
|
+
"@ai-sdk/amazon-bedrock": "4.0.138",
|
|
23
23
|
"@ai-sdk/anthropic": "3.0.81",
|
|
24
24
|
"@ai-sdk/azure": "3.0.68",
|
|
25
25
|
"@ai-sdk/google-vertex": "4.0.140",
|
package/src/agent.js
CHANGED
|
@@ -19,6 +19,7 @@ export const createMemoryConversationStore = () => {
|
|
|
19
19
|
export class Agent extends AIToolLoopAgent {
|
|
20
20
|
#prompt;
|
|
21
21
|
#modelId;
|
|
22
|
+
#providerId;
|
|
22
23
|
#initialMessages;
|
|
23
24
|
#store;
|
|
24
25
|
|
|
@@ -57,6 +58,7 @@ export class Agent extends AIToolLoopAgent {
|
|
|
57
58
|
|
|
58
59
|
this.#prompt = prompt;
|
|
59
60
|
this.#modelId = loadedPrompt.config.model;
|
|
61
|
+
this.#providerId = loadedPrompt.config.provider;
|
|
60
62
|
// `messages` is system-free but may still hold authored <assistant>/<tool>
|
|
61
63
|
// blocks; seed only <user> turns into each generate()/stream() call.
|
|
62
64
|
this.#initialMessages = messages.filter( isRole( ROLE.USER ) );
|
|
@@ -79,7 +81,7 @@ export class Agent extends AIToolLoopAgent {
|
|
|
79
81
|
try {
|
|
80
82
|
const messages = await this.#fetchMessages( userMessages );
|
|
81
83
|
const response = await super.generate( { messages, allowSystemInMessages: true, ...callOptions } );
|
|
82
|
-
const wrapped = await wrapTextResponse( { traceId, response, modelId: this.#modelId } );
|
|
84
|
+
const wrapped = await wrapTextResponse( { traceId, response, providerId: this.#providerId, modelId: this.#modelId } );
|
|
83
85
|
await this.#storeMessages( userMessages, wrapped );
|
|
84
86
|
return wrapped;
|
|
85
87
|
} catch ( error ) {
|
|
@@ -96,7 +98,7 @@ export class Agent extends AIToolLoopAgent {
|
|
|
96
98
|
messages,
|
|
97
99
|
allowSystemInMessages: true,
|
|
98
100
|
...callOptions,
|
|
99
|
-
...wrapStreamOnFinishResponse( { traceId, modelId: this.#modelId, onFinish } ),
|
|
101
|
+
...wrapStreamOnFinishResponse( { traceId, modelId: this.#modelId, providerId: this.#providerId, onFinish } ),
|
|
100
102
|
onError( event ) {
|
|
101
103
|
endTraceWithError( { traceId, error: event.error } );
|
|
102
104
|
onError?.( event );
|
package/src/agent.spec.js
CHANGED
|
@@ -100,7 +100,7 @@ const importSut = async () => import( './agent.js' );
|
|
|
100
100
|
|
|
101
101
|
const loadedPrompt = {
|
|
102
102
|
name: 'test@v1',
|
|
103
|
-
config: { model: 'test-model' },
|
|
103
|
+
config: { provider: 'openai', model: 'test-model' },
|
|
104
104
|
messages: [
|
|
105
105
|
{ role: 'system', content: 'You are concise.' },
|
|
106
106
|
{ role: 'user', content: 'Initial user message' }
|
|
@@ -373,6 +373,7 @@ describe( 'Agent', () => {
|
|
|
373
373
|
} );
|
|
374
374
|
expect( wrapMocks.wrapTextResponse ).toHaveBeenCalledWith( {
|
|
375
375
|
traceId: 'trace-id',
|
|
376
|
+
providerId: 'openai',
|
|
376
377
|
modelId: 'test-model',
|
|
377
378
|
response: aiResponse
|
|
378
379
|
} );
|
|
@@ -422,6 +423,7 @@ describe( 'Agent', () => {
|
|
|
422
423
|
} );
|
|
423
424
|
expect( wrapMocks.wrapStreamOnFinishResponse ).toHaveBeenCalledWith( {
|
|
424
425
|
traceId: 'trace-id',
|
|
426
|
+
providerId: 'openai',
|
|
425
427
|
modelId: 'test-model',
|
|
426
428
|
onFinish
|
|
427
429
|
} );
|
package/src/ai_provider.js
CHANGED
|
@@ -7,6 +7,7 @@ import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
|
|
|
7
7
|
import { createOpenAI } from '@ai-sdk/openai';
|
|
8
8
|
import { createPerplexity } from '@ai-sdk/perplexity';
|
|
9
9
|
import { createVertex } from '@ai-sdk/google-vertex';
|
|
10
|
+
import { deprecatedProviderAliases } from './deprecated_provider_aliases.js';
|
|
10
11
|
|
|
11
12
|
/** This custom dispatcher has longer timeouts. */
|
|
12
13
|
const customDispatcher = new EnvHttpProxyAgent( {
|
|
@@ -20,12 +21,12 @@ const customFetch = ( input, init ) => fetch( input, { dispatcher: customDispatc
|
|
|
20
21
|
|
|
21
22
|
/** Available provider to initialize. */
|
|
22
23
|
const providerInitializers = {
|
|
24
|
+
'amazon-bedrock': createAmazonBedrock,
|
|
23
25
|
anthropic: createAnthropic,
|
|
24
26
|
azure: createAzure,
|
|
25
|
-
|
|
27
|
+
'google-vertex': createVertex,
|
|
26
28
|
openai: createOpenAI,
|
|
27
|
-
perplexity: createPerplexity
|
|
28
|
-
vertex: createVertex
|
|
29
|
+
perplexity: createPerplexity
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
/** Providers already initialized due usage */
|
|
@@ -57,6 +58,12 @@ export function registerProvider( name, providerFn ) {
|
|
|
57
58
|
if ( !result.success ) {
|
|
58
59
|
throw new ValidationError( `Invalid provider registration: ${z.prettifyError( result.error )}` );
|
|
59
60
|
}
|
|
61
|
+
if ( Object.hasOwn( deprecatedProviderAliases, name ) ) {
|
|
62
|
+
const canonical = deprecatedProviderAliases[name];
|
|
63
|
+
throw new ValidationError(
|
|
64
|
+
`Cannot register provider "${name}": that name is a deprecated alias for "${canonical}". Register "${canonical}" instead.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
60
67
|
registeredProviders[name] = providerFn;
|
|
61
68
|
}
|
|
62
69
|
|
package/src/ai_provider.spec.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
3
|
const SHIPPED_PROVIDERS = [
|
|
4
|
+
{ name: 'amazon-bedrock', pkg: '@ai-sdk/amazon-bedrock', exportName: 'createAmazonBedrock' },
|
|
4
5
|
{ name: 'anthropic', pkg: '@ai-sdk/anthropic', exportName: 'createAnthropic' },
|
|
5
6
|
{ name: 'azure', pkg: '@ai-sdk/azure', exportName: 'createAzure' },
|
|
6
|
-
{ name: '
|
|
7
|
+
{ name: 'google-vertex', pkg: '@ai-sdk/google-vertex', exportName: 'createVertex' },
|
|
7
8
|
{ name: 'openai', pkg: '@ai-sdk/openai', exportName: 'createOpenAI' },
|
|
8
|
-
{ name: 'perplexity', pkg: '@ai-sdk/perplexity', exportName: 'createPerplexity' }
|
|
9
|
-
{ name: 'vertex', pkg: '@ai-sdk/google-vertex', exportName: 'createVertex' }
|
|
9
|
+
{ name: 'perplexity', pkg: '@ai-sdk/perplexity', exportName: 'createPerplexity' }
|
|
10
10
|
];
|
|
11
11
|
|
|
12
12
|
const makeProviderModules = () => Object.fromEntries(
|
|
@@ -184,6 +184,17 @@ describe( 'registerProvider', () => {
|
|
|
184
184
|
expect( () => registerProvider( '', vi.fn() ) ).toThrow( 'Provider name must be a non-empty string' );
|
|
185
185
|
expect( () => registerProvider( 'custom', 'not-a-function' ) ).toThrow( 'expected function, received string' );
|
|
186
186
|
} );
|
|
187
|
+
|
|
188
|
+
it.each( [
|
|
189
|
+
[ 'vertex', 'google-vertex' ],
|
|
190
|
+
[ 'bedrock', 'amazon-bedrock' ]
|
|
191
|
+
] )( 'rejects registering deprecated provider alias %s', async ( alias, canonical ) => {
|
|
192
|
+
const { registerProvider } = await importWithMockedProviders();
|
|
193
|
+
|
|
194
|
+
expect( () => registerProvider( alias, vi.fn() ) ).toThrow(
|
|
195
|
+
`Cannot register provider "${alias}": that name is a deprecated alias for "${canonical}". Register "${canonical}" instead.`
|
|
196
|
+
);
|
|
197
|
+
} );
|
|
187
198
|
} );
|
|
188
199
|
|
|
189
200
|
describe( 'getProviderNames', () => {
|
|
@@ -194,12 +205,12 @@ describe( 'getProviderNames', () => {
|
|
|
194
205
|
registerProvider( 'openai', vi.fn() );
|
|
195
206
|
|
|
196
207
|
expect( getProviderNames() ).toEqual( [
|
|
208
|
+
'amazon-bedrock',
|
|
197
209
|
'anthropic',
|
|
198
210
|
'azure',
|
|
199
|
-
'
|
|
211
|
+
'google-vertex',
|
|
200
212
|
'openai',
|
|
201
213
|
'perplexity',
|
|
202
|
-
'vertex',
|
|
203
214
|
'custom'
|
|
204
215
|
] );
|
|
205
216
|
} );
|
package/src/ai_sdk.js
CHANGED
|
@@ -17,7 +17,7 @@ export async function generateText( { prompt, variables, promptDir, skills = [],
|
|
|
17
17
|
const { loadedPrompt, tools } = prepareTextPrompt( { prompt, variables, promptDir, skills: parsedSkills, tools: aiSdkArgs.tools } );
|
|
18
18
|
|
|
19
19
|
const traceId = startTrace( { name: 'generateText', prompt, variables, loadedPrompt } );
|
|
20
|
-
const { model: modelId } = loadedPrompt.config;
|
|
20
|
+
const { model: modelId, provider: providerId } = loadedPrompt.config;
|
|
21
21
|
|
|
22
22
|
try {
|
|
23
23
|
const response = await AI.generateText( {
|
|
@@ -28,7 +28,7 @@ export async function generateText( { prompt, variables, promptDir, skills = [],
|
|
|
28
28
|
...( tools && { tools } ),
|
|
29
29
|
...( tools && !aiSdkArgs.stopWhen ? { stopWhen: stepCountIs( maxSteps ) } : {} )
|
|
30
30
|
} );
|
|
31
|
-
return wrapTextResponse( { traceId, modelId, response } );
|
|
31
|
+
return wrapTextResponse( { traceId, providerId, modelId, response } );
|
|
32
32
|
} catch ( originalError ) {
|
|
33
33
|
const error = mapAiError( originalError );
|
|
34
34
|
endTraceWithError( { traceId, error } );
|
|
@@ -46,7 +46,7 @@ export function streamText( { prompt, variables, promptDir, skills = [], maxStep
|
|
|
46
46
|
const { loadedPrompt, tools } = prepareTextPrompt( { prompt, variables, promptDir, skills: parsedSkills, tools: aiSdkArgs.tools } );
|
|
47
47
|
|
|
48
48
|
const traceId = startTrace( { name: 'streamText', prompt, variables, loadedPrompt } );
|
|
49
|
-
const { model: modelId } = loadedPrompt.config;
|
|
49
|
+
const { model: modelId, provider: providerId } = loadedPrompt.config;
|
|
50
50
|
|
|
51
51
|
try {
|
|
52
52
|
return AI.streamText( {
|
|
@@ -56,7 +56,7 @@ export function streamText( { prompt, variables, promptDir, skills = [], maxStep
|
|
|
56
56
|
...aiSdkArgs,
|
|
57
57
|
...( tools && { tools } ),
|
|
58
58
|
...( tools && !aiSdkArgs.stopWhen ? { stopWhen: stepCountIs( maxSteps ) } : {} ),
|
|
59
|
-
...wrapStreamOnFinishResponse( { traceId, modelId, onFinish } ),
|
|
59
|
+
...wrapStreamOnFinishResponse( { traceId, modelId, providerId, onFinish } ),
|
|
60
60
|
onError( event ) {
|
|
61
61
|
const error = mapAiError( event.error );
|
|
62
62
|
endTraceWithError( { traceId, error } );
|
|
@@ -75,7 +75,7 @@ export async function generateImage( { prompt, variables, promptDir, images, mas
|
|
|
75
75
|
|
|
76
76
|
const loadedPrompt = loadPrompt( prompt, variables, promptDir );
|
|
77
77
|
const traceId = startTrace( { name: 'generateImage', prompt, variables, loadedPrompt } );
|
|
78
|
-
const { model: modelId } = loadedPrompt.config;
|
|
78
|
+
const { model: modelId, provider: providerId } = loadedPrompt.config;
|
|
79
79
|
|
|
80
80
|
try {
|
|
81
81
|
const response = await AI.generateImage( {
|
|
@@ -83,7 +83,7 @@ export async function generateImage( { prompt, variables, promptDir, images, mas
|
|
|
83
83
|
maxRetries: 0,
|
|
84
84
|
...aiSdkArgs
|
|
85
85
|
} );
|
|
86
|
-
return wrapImageResponse( { traceId, modelId, response } );
|
|
86
|
+
return wrapImageResponse( { traceId, providerId, modelId, response } );
|
|
87
87
|
} catch ( originalError ) {
|
|
88
88
|
const error = mapAiError( originalError );
|
|
89
89
|
endTraceWithError( { traceId, error } );
|
package/src/ai_sdk.spec.js
CHANGED
|
@@ -74,7 +74,7 @@ const importSut = async () => import( './ai_sdk.js' );
|
|
|
74
74
|
|
|
75
75
|
const loadedPrompt = {
|
|
76
76
|
name: 'test@v1',
|
|
77
|
-
config: { model: 'test-model' },
|
|
77
|
+
config: { provider: 'openai', model: 'test-model' },
|
|
78
78
|
messages: [ { role: 'user', content: 'Hello' } ]
|
|
79
79
|
};
|
|
80
80
|
|
|
@@ -198,6 +198,7 @@ describe( 'ai_sdk', () => {
|
|
|
198
198
|
} );
|
|
199
199
|
expect( wrapMocks.wrapTextResponse ).toHaveBeenCalledWith( {
|
|
200
200
|
traceId: 'trace-id',
|
|
201
|
+
providerId: 'openai',
|
|
201
202
|
modelId: 'test-model',
|
|
202
203
|
response: textResponse
|
|
203
204
|
} );
|
|
@@ -322,6 +323,7 @@ describe( 'ai_sdk', () => {
|
|
|
322
323
|
expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( loadedPrompt );
|
|
323
324
|
expect( wrapMocks.wrapStreamOnFinishResponse ).toHaveBeenCalledWith( {
|
|
324
325
|
traceId: 'trace-id',
|
|
326
|
+
providerId: 'openai',
|
|
325
327
|
modelId: 'test-model',
|
|
326
328
|
onFinish
|
|
327
329
|
} );
|
|
@@ -505,6 +507,7 @@ describe( 'ai_sdk', () => {
|
|
|
505
507
|
} );
|
|
506
508
|
expect( wrapMocks.wrapImageResponse ).toHaveBeenCalledWith( {
|
|
507
509
|
traceId: 'trace-id',
|
|
510
|
+
providerId: 'openai',
|
|
508
511
|
modelId: 'test-model',
|
|
509
512
|
response: imageResponse
|
|
510
513
|
} );
|
|
@@ -18,7 +18,6 @@ const buildModelMap = data => {
|
|
|
18
18
|
for ( const provider of Object.values( data ) ) {
|
|
19
19
|
for ( const [ modelName, { cost } ] of Object.entries( provider.models ?? {} ) ) {
|
|
20
20
|
if ( cost ) { // some models don't have cost
|
|
21
|
-
map.set( modelName, cost );
|
|
22
21
|
map.set( `${provider.id}/${modelName}`, cost );
|
|
23
22
|
}
|
|
24
23
|
}
|
|
@@ -47,7 +47,7 @@ describe( 'fetchModelsPricing', () => {
|
|
|
47
47
|
const firstModel = Object.values( fixture )[0];
|
|
48
48
|
const firstModelId = Object.keys( firstModel.models )[0];
|
|
49
49
|
const cost = firstModel.models[firstModelId].cost;
|
|
50
|
-
expect( result.get( firstModelId ) ).
|
|
50
|
+
expect( result.get( firstModelId ) ).toBeUndefined();
|
|
51
51
|
expect( result.get( `${firstModel.id}/${firstModelId}` ) ).toEqual( cost );
|
|
52
52
|
} );
|
|
53
53
|
|
|
@@ -58,11 +58,23 @@ describe( 'fetchModelsPricing', () => {
|
|
|
58
58
|
|
|
59
59
|
const openaiProvider = fixture.openai;
|
|
60
60
|
const openaiModelId = Object.keys( openaiProvider.models )[0];
|
|
61
|
-
expect( result.get( openaiModelId ) ).
|
|
61
|
+
expect( result.get( openaiModelId ) ).toBeUndefined();
|
|
62
62
|
expect( result.get( `openai/${openaiModelId}` ) ).toEqual( openaiProvider.models[openaiModelId].cost );
|
|
63
63
|
|
|
64
64
|
const anthropicModelId = Object.keys( fixture.anthropic.models )[0];
|
|
65
|
-
expect( result.get( anthropicModelId ) ).
|
|
65
|
+
expect( result.get( `anthropic/${anthropicModelId}` ) ).toEqual( fixture.anthropic.models[anthropicModelId].cost );
|
|
66
|
+
} );
|
|
67
|
+
|
|
68
|
+
it( 'keeps separate costs when the same model id exists under two providers', async () => {
|
|
69
|
+
stubFetch( okResponse( fixture ) );
|
|
70
|
+
|
|
71
|
+
const result = await fetchModelsPricing();
|
|
72
|
+
const sharedModelId = 'gpt-4o-2024-11-20';
|
|
73
|
+
|
|
74
|
+
expect( result.get( sharedModelId ) ).toBeUndefined();
|
|
75
|
+
expect( result.get( `openai/${sharedModelId}` ) ).toEqual( fixture.openai.models[sharedModelId].cost );
|
|
76
|
+
expect( result.get( `azure/${sharedModelId}` ) ).toEqual( fixture.azure.models[sharedModelId].cost );
|
|
77
|
+
expect( result.get( `openai/${sharedModelId}` ) ).not.toEqual( result.get( `azure/${sharedModelId}` ) );
|
|
66
78
|
} );
|
|
67
79
|
|
|
68
80
|
it( 'returns null when response is not ok and no cache', async () => {
|
|
@@ -149,7 +161,8 @@ describe( 'fetchModelsPricing', () => {
|
|
|
149
161
|
|
|
150
162
|
const result = await fetchModelsPricing();
|
|
151
163
|
|
|
152
|
-
expect( result.get( 'withCost' ) ).toEqual( { input: 1, output: 2 } );
|
|
153
|
-
expect( result.get( '
|
|
164
|
+
expect( result.get( 'p1/withCost' ) ).toEqual( { input: 1, output: 2 } );
|
|
165
|
+
expect( result.get( 'withCost' ) ).toBeUndefined();
|
|
166
|
+
expect( result.get( 'p1/noCost' ) ).toBeUndefined();
|
|
154
167
|
} );
|
|
155
168
|
} );
|
|
@@ -135,6 +135,20 @@
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
},
|
|
138
|
+
"azure": {
|
|
139
|
+
"id": "azure",
|
|
140
|
+
"models": {
|
|
141
|
+
"gpt-4o-2024-11-20": {
|
|
142
|
+
"id": "gpt-4o-2024-11-20",
|
|
143
|
+
"name": "GPT-4o (2024-11-20)",
|
|
144
|
+
"cost": {
|
|
145
|
+
"input": 3,
|
|
146
|
+
"output": 12,
|
|
147
|
+
"cache_read": 1.5
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
},
|
|
138
152
|
"anthropic": {
|
|
139
153
|
"id": "anthropic",
|
|
140
154
|
"models": {
|
package/src/cost/index.js
CHANGED
|
@@ -5,11 +5,12 @@ import { Logger } from '@outputai/core';
|
|
|
5
5
|
/**
|
|
6
6
|
* Calculates the cost of an llm call based on the model and usage.
|
|
7
7
|
* @param {object} args
|
|
8
|
-
* @param {string} args.
|
|
8
|
+
* @param {string} args.providerId - Id of the provider
|
|
9
|
+
* @param {string} args.modelId - Id of the model
|
|
9
10
|
* @param {object} args.usage - Usage, as returned from AI SDK
|
|
10
11
|
* @returns {object} The cost with total value and components
|
|
11
12
|
*/
|
|
12
|
-
export const calculateLLMCallCost = async ( { modelId, usage } ) => {
|
|
13
|
+
export const calculateLLMCallCost = async ( { providerId, modelId, usage } ) => {
|
|
13
14
|
try {
|
|
14
15
|
const models = await fetchModelsPricing();
|
|
15
16
|
|
|
@@ -18,9 +19,9 @@ export const calculateLLMCallCost = async ( { modelId, usage } ) => {
|
|
|
18
19
|
return null;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
const pricing = models.get( modelId );
|
|
22
|
+
const pricing = models.get( `${providerId}/${modelId}` );
|
|
22
23
|
if ( !pricing ) {
|
|
23
|
-
Logger.warn( 'Missing cost reference for model', { namespace: 'LLM' } );
|
|
24
|
+
Logger.warn( 'Missing cost reference for model', { namespace: 'LLM', modelId, providerId } );
|
|
24
25
|
return null;
|
|
25
26
|
}
|
|
26
27
|
|
package/src/cost/index.spec.js
CHANGED
|
@@ -47,6 +47,11 @@ vi.mock( '@outputai/core/sdk/runtime', () => {
|
|
|
47
47
|
import { Tracing } from '@outputai/core/sdk/runtime';
|
|
48
48
|
import { calculateLLMCallCost } from './index.js';
|
|
49
49
|
|
|
50
|
+
const pricingMap = entries => new Map( entries.map( ( [ providerId, modelId, cost ] ) => [
|
|
51
|
+
`${providerId}/${modelId}`,
|
|
52
|
+
cost
|
|
53
|
+
] ) );
|
|
54
|
+
|
|
50
55
|
const expectLLMUsage = ( result, { modelId, usage, total, tokensUsed } ) => {
|
|
51
56
|
expect( result ).toBeInstanceOf( Tracing.Attribute.LLMUsage );
|
|
52
57
|
expect( result ).toEqual( expect.objectContaining( {
|
|
@@ -71,6 +76,7 @@ describe( 'calculateLLMCallCost', () => {
|
|
|
71
76
|
mockFetchModelsPricing.mockResolvedValue( null );
|
|
72
77
|
|
|
73
78
|
const result = await calculateLLMCallCost( {
|
|
79
|
+
providerId: 'openai',
|
|
74
80
|
modelId: 'gpt-4o',
|
|
75
81
|
usage: { inputTokens: 100, outputTokens: 50 }
|
|
76
82
|
} );
|
|
@@ -82,6 +88,7 @@ describe( 'calculateLLMCallCost', () => {
|
|
|
82
88
|
mockFetchModelsPricing.mockResolvedValue( new Map() );
|
|
83
89
|
|
|
84
90
|
const result = await calculateLLMCallCost( {
|
|
91
|
+
providerId: 'openai',
|
|
85
92
|
modelId: 'unknown-model',
|
|
86
93
|
usage: { inputTokens: 100, outputTokens: 50 }
|
|
87
94
|
} );
|
|
@@ -90,9 +97,12 @@ describe( 'calculateLLMCallCost', () => {
|
|
|
90
97
|
} );
|
|
91
98
|
|
|
92
99
|
it( 'calculates input and output usage from model pricing', async () => {
|
|
93
|
-
mockFetchModelsPricing.mockResolvedValue(
|
|
100
|
+
mockFetchModelsPricing.mockResolvedValue( pricingMap( [
|
|
101
|
+
[ 'openai', 'gpt-4o', { input: 2, output: 10, cache_read: 1 } ]
|
|
102
|
+
] ) );
|
|
94
103
|
|
|
95
104
|
const result = await calculateLLMCallCost( {
|
|
105
|
+
providerId: 'openai',
|
|
96
106
|
modelId: 'gpt-4o',
|
|
97
107
|
usage: { inputTokens: 1_000_000, outputTokens: 500_000 }
|
|
98
108
|
} );
|
|
@@ -108,10 +118,51 @@ describe( 'calculateLLMCallCost', () => {
|
|
|
108
118
|
} );
|
|
109
119
|
} );
|
|
110
120
|
|
|
121
|
+
it( 'uses each provider pricing when the same model id exists under two providers', async () => {
|
|
122
|
+
mockFetchModelsPricing.mockResolvedValue( pricingMap( [
|
|
123
|
+
[ 'openai', 'gpt-4o', { input: 2, output: 10 } ],
|
|
124
|
+
[ 'azure', 'gpt-4o', { input: 3, output: 12 } ]
|
|
125
|
+
] ) );
|
|
126
|
+
const usage = { inputTokens: 1_000_000, outputTokens: 0 };
|
|
127
|
+
|
|
128
|
+
const openai = await calculateLLMCallCost( {
|
|
129
|
+
providerId: 'openai',
|
|
130
|
+
modelId: 'gpt-4o',
|
|
131
|
+
usage
|
|
132
|
+
} );
|
|
133
|
+
const azure = await calculateLLMCallCost( {
|
|
134
|
+
providerId: 'azure',
|
|
135
|
+
modelId: 'gpt-4o',
|
|
136
|
+
usage
|
|
137
|
+
} );
|
|
138
|
+
|
|
139
|
+
expectLLMUsage( openai, {
|
|
140
|
+
modelId: 'gpt-4o',
|
|
141
|
+
usage: [
|
|
142
|
+
{ type: 'input', ppm: 2, amount: 1_000_000, total: 2 },
|
|
143
|
+
{ type: 'output', ppm: 10, amount: 0, total: 0 }
|
|
144
|
+
],
|
|
145
|
+
total: 2,
|
|
146
|
+
tokensUsed: 1_000_000
|
|
147
|
+
} );
|
|
148
|
+
expectLLMUsage( azure, {
|
|
149
|
+
modelId: 'gpt-4o',
|
|
150
|
+
usage: [
|
|
151
|
+
{ type: 'input', ppm: 3, amount: 1_000_000, total: 3 },
|
|
152
|
+
{ type: 'output', ppm: 12, amount: 0, total: 0 }
|
|
153
|
+
],
|
|
154
|
+
total: 3,
|
|
155
|
+
tokensUsed: 1_000_000
|
|
156
|
+
} );
|
|
157
|
+
} );
|
|
158
|
+
|
|
111
159
|
it( 'splits input into non-cached and cached usage at respective rates', async () => {
|
|
112
|
-
mockFetchModelsPricing.mockResolvedValue(
|
|
160
|
+
mockFetchModelsPricing.mockResolvedValue( pricingMap( [
|
|
161
|
+
[ 'openai', 'cached-model', { input: 4, cache_read: 1, output: 10 } ]
|
|
162
|
+
] ) );
|
|
113
163
|
|
|
114
164
|
const result = await calculateLLMCallCost( {
|
|
165
|
+
providerId: 'openai',
|
|
115
166
|
modelId: 'cached-model',
|
|
116
167
|
usage: { inputTokens: 1_000_000, cachedInputTokens: 500_000, outputTokens: 100_000 }
|
|
117
168
|
} );
|
|
@@ -129,9 +180,12 @@ describe( 'calculateLLMCallCost', () => {
|
|
|
129
180
|
} );
|
|
130
181
|
|
|
131
182
|
it( 'still counts cached tokens when the model has no cache_read rate', async () => {
|
|
132
|
-
mockFetchModelsPricing.mockResolvedValue(
|
|
183
|
+
mockFetchModelsPricing.mockResolvedValue( pricingMap( [
|
|
184
|
+
[ 'openai', 'no-cache', { input: 2, output: 10 } ]
|
|
185
|
+
] ) );
|
|
133
186
|
|
|
134
187
|
const result = await calculateLLMCallCost( {
|
|
188
|
+
providerId: 'openai',
|
|
135
189
|
modelId: 'no-cache',
|
|
136
190
|
usage: { inputTokens: 1_000_000, cachedInputTokens: 200_000, outputTokens: 0 }
|
|
137
191
|
} );
|
|
@@ -151,9 +205,12 @@ describe( 'calculateLLMCallCost', () => {
|
|
|
151
205
|
} );
|
|
152
206
|
|
|
153
207
|
it( 'omits input usage when pricing has no input rate', async () => {
|
|
154
|
-
mockFetchModelsPricing.mockResolvedValue(
|
|
208
|
+
mockFetchModelsPricing.mockResolvedValue( pricingMap( [
|
|
209
|
+
[ 'openai', 'out-only', { output: 10 } ]
|
|
210
|
+
] ) );
|
|
155
211
|
|
|
156
212
|
const result = await calculateLLMCallCost( {
|
|
213
|
+
providerId: 'openai',
|
|
157
214
|
modelId: 'out-only',
|
|
158
215
|
usage: { inputTokens: 100, outputTokens: 50 }
|
|
159
216
|
} );
|
|
@@ -169,9 +226,12 @@ describe( 'calculateLLMCallCost', () => {
|
|
|
169
226
|
} );
|
|
170
227
|
|
|
171
228
|
it( 'omits output usage when pricing has no output rate', async () => {
|
|
172
|
-
mockFetchModelsPricing.mockResolvedValue(
|
|
229
|
+
mockFetchModelsPricing.mockResolvedValue( pricingMap( [
|
|
230
|
+
[ 'openai', 'in-only', { input: 1 } ]
|
|
231
|
+
] ) );
|
|
173
232
|
|
|
174
233
|
const result = await calculateLLMCallCost( {
|
|
234
|
+
providerId: 'openai',
|
|
175
235
|
modelId: 'in-only',
|
|
176
236
|
usage: { inputTokens: 100, outputTokens: 50 }
|
|
177
237
|
} );
|
|
@@ -187,12 +247,12 @@ describe( 'calculateLLMCallCost', () => {
|
|
|
187
247
|
} );
|
|
188
248
|
|
|
189
249
|
it( 'includes reasoning usage when present', async () => {
|
|
190
|
-
mockFetchModelsPricing.mockResolvedValue(
|
|
191
|
-
'with-reasoning',
|
|
192
|
-
|
|
193
|
-
] ] ) );
|
|
250
|
+
mockFetchModelsPricing.mockResolvedValue( pricingMap( [
|
|
251
|
+
[ 'openai', 'with-reasoning', { input: 1, output: 10, reasoning: 60 } ]
|
|
252
|
+
] ) );
|
|
194
253
|
|
|
195
254
|
const result = await calculateLLMCallCost( {
|
|
255
|
+
providerId: 'openai',
|
|
196
256
|
modelId: 'with-reasoning',
|
|
197
257
|
usage: { inputTokens: 100, outputTokens: 20, reasoningTokens: 50 }
|
|
198
258
|
} );
|
|
@@ -210,9 +270,12 @@ describe( 'calculateLLMCallCost', () => {
|
|
|
210
270
|
} );
|
|
211
271
|
|
|
212
272
|
it( 'omits reasoning usage when reasoning cost is missing', async () => {
|
|
213
|
-
mockFetchModelsPricing.mockResolvedValue(
|
|
273
|
+
mockFetchModelsPricing.mockResolvedValue( pricingMap( [
|
|
274
|
+
[ 'openai', 'no-reasoning', { input: 1, output: 10 } ]
|
|
275
|
+
] ) );
|
|
214
276
|
|
|
215
277
|
const result = await calculateLLMCallCost( {
|
|
278
|
+
providerId: 'openai',
|
|
216
279
|
modelId: 'no-reasoning',
|
|
217
280
|
usage: { inputTokens: 100, outputTokens: 20, reasoningTokens: 50 }
|
|
218
281
|
} );
|
|
@@ -229,12 +292,12 @@ describe( 'calculateLLMCallCost', () => {
|
|
|
229
292
|
} );
|
|
230
293
|
|
|
231
294
|
it( 'includes reasoning usage with zero amount when reasoningTokens is zero', async () => {
|
|
232
|
-
mockFetchModelsPricing.mockResolvedValue(
|
|
233
|
-
'full',
|
|
234
|
-
|
|
235
|
-
] ] ) );
|
|
295
|
+
mockFetchModelsPricing.mockResolvedValue( pricingMap( [
|
|
296
|
+
[ 'openai', 'full', { input: 2, output: 8, reasoning: 60 } ]
|
|
297
|
+
] ) );
|
|
236
298
|
|
|
237
299
|
const result = await calculateLLMCallCost( {
|
|
300
|
+
providerId: 'openai',
|
|
238
301
|
modelId: 'full',
|
|
239
302
|
usage: { inputTokens: 100, outputTokens: 50, reasoningTokens: 0 }
|
|
240
303
|
} );
|
|
@@ -252,9 +315,12 @@ describe( 'calculateLLMCallCost', () => {
|
|
|
252
315
|
} );
|
|
253
316
|
|
|
254
317
|
it( 'omits usage entries for non-finite token counts', async () => {
|
|
255
|
-
mockFetchModelsPricing.mockResolvedValue(
|
|
318
|
+
mockFetchModelsPricing.mockResolvedValue( pricingMap( [
|
|
319
|
+
[ 'openai', 'm', { input: 1, output: 2 } ]
|
|
320
|
+
] ) );
|
|
256
321
|
|
|
257
322
|
const result = await calculateLLMCallCost( {
|
|
323
|
+
providerId: 'openai',
|
|
258
324
|
modelId: 'm',
|
|
259
325
|
usage: { inputTokens: null, outputTokens: undefined }
|
|
260
326
|
} );
|
|
@@ -274,6 +340,7 @@ describe( 'calculateLLMCallCost', () => {
|
|
|
274
340
|
mockFetchModelsPricing.mockRejectedValue( error );
|
|
275
341
|
|
|
276
342
|
const result = await calculateLLMCallCost( {
|
|
343
|
+
providerId: 'openai',
|
|
277
344
|
modelId: 'gpt-4o',
|
|
278
345
|
usage: { inputTokens: 100, outputTokens: 50 }
|
|
279
346
|
} );
|
package/src/index.d.ts
CHANGED
|
@@ -91,7 +91,13 @@ export type Prompt = {
|
|
|
91
91
|
|
|
92
92
|
/** General configuration for the LLM */
|
|
93
93
|
config: {
|
|
94
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* LLM provider.
|
|
96
|
+
*
|
|
97
|
+
* Built-in: `'anthropic'`, `'openai'`, `'azure'`, `'amazon-bedrock'`, `'google-vertex'`,
|
|
98
|
+
* `'perplexity'`. Legacy aliases `'bedrock'` and `'vertex'` are deprecated but still accepted.
|
|
99
|
+
* Custom providers registered via {@link registerProvider} are also accepted.
|
|
100
|
+
*/
|
|
95
101
|
provider: string;
|
|
96
102
|
|
|
97
103
|
/** Model name/identifier */
|
package/src/prompt/loader.js
CHANGED
|
@@ -2,8 +2,9 @@ import { parsePrompt } from './parser.js';
|
|
|
2
2
|
import { Liquid } from 'liquidjs';
|
|
3
3
|
import { loadContent } from './load_content.js';
|
|
4
4
|
import { validatePrompt } from './validations.js';
|
|
5
|
-
import { FatalError } from '@outputai/core';
|
|
5
|
+
import { FatalError, Logger } from '@outputai/core';
|
|
6
6
|
import { escape, decode, setupLiquidEncodeFilter } from './escape.js';
|
|
7
|
+
import { deprecatedProviderAliases } from '../deprecated_provider_aliases.js';
|
|
7
8
|
|
|
8
9
|
const liquid = new Liquid( {
|
|
9
10
|
strictFilters: true,
|
|
@@ -47,6 +48,13 @@ export const loadPrompt = ( name, values = {}, dir ) => {
|
|
|
47
48
|
instructions: instructions === null ? null : decode( instructions )
|
|
48
49
|
};
|
|
49
50
|
|
|
51
|
+
const provider = prompt.config.provider;
|
|
52
|
+
if ( Object.hasOwn( deprecatedProviderAliases, provider ) ) {
|
|
53
|
+
const canonical = deprecatedProviderAliases[provider];
|
|
54
|
+
Logger.warn( `Using deprecated provider alias "${provider}". Use "${canonical}" instead.`, { namespace: 'LLM' } );
|
|
55
|
+
prompt.config.provider = canonical;
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
validatePrompt( prompt );
|
|
51
59
|
|
|
52
60
|
return { ...prompt, promptFileDir: file.dir };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { Logger } from '@outputai/core';
|
|
2
3
|
import { loadPrompt } from './loader.js';
|
|
3
4
|
|
|
4
5
|
vi.mock( './parser.js', () => ( {
|
|
@@ -263,6 +264,92 @@ model: gpt-4
|
|
|
263
264
|
} );
|
|
264
265
|
} );
|
|
265
266
|
|
|
267
|
+
it.each( [
|
|
268
|
+
[ 'vertex', 'google-vertex' ],
|
|
269
|
+
[ 'bedrock', 'amazon-bedrock' ]
|
|
270
|
+
] )( 'rewrites deprecated provider alias %s to %s and warns', ( alias, canonical ) => {
|
|
271
|
+
const warn = vi.spyOn( Logger, 'warn' ).mockImplementation( () => {} );
|
|
272
|
+
loadContent.mockReturnValue( {
|
|
273
|
+
content: '<user>Hello</user>',
|
|
274
|
+
dir: '/mock/dir'
|
|
275
|
+
} );
|
|
276
|
+
parsePrompt.mockReturnValue( {
|
|
277
|
+
config: {
|
|
278
|
+
provider: alias,
|
|
279
|
+
model: 'test-model'
|
|
280
|
+
},
|
|
281
|
+
messages: [ { role: 'user', content: 'Hello' } ],
|
|
282
|
+
instructions: null
|
|
283
|
+
} );
|
|
284
|
+
|
|
285
|
+
const result = loadPrompt( 'test' );
|
|
286
|
+
|
|
287
|
+
expect( result.config.provider ).toBe( canonical );
|
|
288
|
+
expect( validatePrompt ).toHaveBeenCalledWith( expect.objectContaining( {
|
|
289
|
+
config: expect.objectContaining( { provider: canonical } )
|
|
290
|
+
} ) );
|
|
291
|
+
expect( warn ).toHaveBeenCalledWith(
|
|
292
|
+
`Using deprecated provider alias "${alias}". Use "${canonical}" instead.`,
|
|
293
|
+
{ namespace: 'LLM' }
|
|
294
|
+
);
|
|
295
|
+
} );
|
|
296
|
+
|
|
297
|
+
it( 'rewrites provider aliases on the decoded config', () => {
|
|
298
|
+
const warn = vi.spyOn( Logger, 'warn' ).mockImplementation( () => {} );
|
|
299
|
+
const parsedConfig = {
|
|
300
|
+
provider: 'bedrock',
|
|
301
|
+
model: 'test-model'
|
|
302
|
+
};
|
|
303
|
+
const decodedConfig = {
|
|
304
|
+
provider: 'bedrock',
|
|
305
|
+
model: 'test-model'
|
|
306
|
+
};
|
|
307
|
+
loadContent.mockReturnValue( {
|
|
308
|
+
content: '<user>Hello</user>',
|
|
309
|
+
dir: '/mock/dir'
|
|
310
|
+
} );
|
|
311
|
+
parsePrompt.mockReturnValue( {
|
|
312
|
+
config: parsedConfig,
|
|
313
|
+
messages: [ { role: 'user', content: 'Hello' } ],
|
|
314
|
+
instructions: null
|
|
315
|
+
} );
|
|
316
|
+
decode.mockImplementation( value => ( value === parsedConfig ? decodedConfig : value ) );
|
|
317
|
+
|
|
318
|
+
const result = loadPrompt( 'test' );
|
|
319
|
+
|
|
320
|
+
expect( decode ).toHaveBeenCalledWith( parsedConfig );
|
|
321
|
+
expect( result.config ).toBe( decodedConfig );
|
|
322
|
+
expect( result.config.provider ).toBe( 'amazon-bedrock' );
|
|
323
|
+
expect( validatePrompt ).toHaveBeenCalledWith( expect.objectContaining( {
|
|
324
|
+
config: decodedConfig
|
|
325
|
+
} ) );
|
|
326
|
+
expect( warn ).toHaveBeenCalledWith(
|
|
327
|
+
'Using deprecated provider alias "bedrock". Use "amazon-bedrock" instead.',
|
|
328
|
+
{ namespace: 'LLM' }
|
|
329
|
+
);
|
|
330
|
+
} );
|
|
331
|
+
|
|
332
|
+
it( 'leaves canonical provider names unchanged', () => {
|
|
333
|
+
const warn = vi.spyOn( Logger, 'warn' ).mockImplementation( () => {} );
|
|
334
|
+
loadContent.mockReturnValue( {
|
|
335
|
+
content: '<user>Hello</user>',
|
|
336
|
+
dir: '/mock/dir'
|
|
337
|
+
} );
|
|
338
|
+
parsePrompt.mockReturnValue( {
|
|
339
|
+
config: {
|
|
340
|
+
provider: 'google-vertex',
|
|
341
|
+
model: 'gemini-2.5-flash-lite'
|
|
342
|
+
},
|
|
343
|
+
messages: [ { role: 'user', content: 'Hello' } ],
|
|
344
|
+
instructions: null
|
|
345
|
+
} );
|
|
346
|
+
|
|
347
|
+
const result = loadPrompt( 'test' );
|
|
348
|
+
|
|
349
|
+
expect( result.config.provider ).toBe( 'google-vertex' );
|
|
350
|
+
expect( warn ).not.toHaveBeenCalled();
|
|
351
|
+
} );
|
|
352
|
+
|
|
266
353
|
it( 'throws error when prompt file not found', () => {
|
|
267
354
|
loadContent.mockReturnValue( null );
|
|
268
355
|
|
|
@@ -1,56 +1,26 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
NoSuchProviderError,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
// AI SDK does not expose a dedicated schema-mismatch discriminator for NoObjectGeneratedError.
|
|
17
|
-
const NO_OBJECT_SCHEMA_MISMATCH_MESSAGE = 'No object generated: response did not match schema.';
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Recursively search an error cause chain until finds an error which is instance of given prototype.
|
|
21
|
-
*
|
|
22
|
-
* @param {object} error - Error instance.
|
|
23
|
-
* @param {Function|string} _class - Target constructor or constructor name.
|
|
24
|
-
* @param {number} depth - Current depth, search up to 10 causes deep.
|
|
25
|
-
* @returns {object|null} - Error or null if not found.
|
|
26
|
-
*/
|
|
27
|
-
export const findInstanceInCauseChain = ( error, _class, depth = 0 ) => {
|
|
28
|
-
if ( !error || typeof error !== 'object' ) {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
if ( typeof _class === 'string' && error.constructor?.name === _class ) {
|
|
32
|
-
return error;
|
|
33
|
-
}
|
|
34
|
-
if ( typeof _class === 'function' && error instanceof _class ) {
|
|
35
|
-
return error;
|
|
36
|
-
}
|
|
37
|
-
if ( depth >= 10 ) {
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
return error.cause ? findInstanceInCauseChain( error.cause, _class, depth + 1 ) : null;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const toFatalError = ( error, extraMessage = '' ) => new FatalError(
|
|
44
|
-
`AI-SDK fatal error${extraMessage ? ` (${extraMessage})` : ''}: ${error.message}`,
|
|
45
|
-
{ cause: error }
|
|
46
|
-
);
|
|
1
|
+
import * as ai from 'ai';
|
|
2
|
+
import { FatalError, TransparentFatalError } from '@outputai/core';
|
|
3
|
+
|
|
4
|
+
const nonRetryableAiSdkErrorTypes = [
|
|
5
|
+
ai.InvalidArgumentError, // Invalid call settings are deterministic caller bugs, so retrying the same activity cannot fix them.
|
|
6
|
+
ai.InvalidDataContentError, // Invalid media content has the wrong local shape/encoding and will fail again with the same input.
|
|
7
|
+
ai.InvalidPromptError, // Invalid prompt structure is a deterministic request-construction error.
|
|
8
|
+
ai.LoadAPIKeyError, // Missing or invalid API key configuration will not change during an activity retry.
|
|
9
|
+
ai.LoadSettingError, // Missing or invalid provider settings are deployment/configuration problems.
|
|
10
|
+
ai.NoImageGeneratedError, // Image generation completed provider calls but collected zero images; repeating identical input is not useful.
|
|
11
|
+
ai.NoSuchProviderError, // A missing provider id is a deterministic provider registry/configuration error.
|
|
12
|
+
ai.NoSuchModelError, // A missing model id is a deterministic provider/model configuration error.
|
|
13
|
+
ai.UnsupportedFunctionalityError // The selected model/output mode does not support the requested feature.
|
|
14
|
+
];
|
|
47
15
|
|
|
48
16
|
/**
|
|
49
|
-
*
|
|
17
|
+
* Maps an AI SDK error to a framework error:
|
|
18
|
+
* - AI SDK Unrecoverable error types become TransparentFatalErrors;
|
|
19
|
+
* - AI SDK API error with isRetryable=false become TransparentFatalErrors;
|
|
20
|
+
* Some errors are not mapped:
|
|
21
|
+
* - Grammar which technically are isRetryable=false, will be rethrown, because they are actually transient;
|
|
22
|
+
* - Other errors are rethrown as well;
|
|
50
23
|
*
|
|
51
|
-
* - AI SDK Unrecoverable errors become FatalErrors, check code to see options.
|
|
52
|
-
* - NoObjectGeneratedError from invalid schema are reinitialized with a better message.
|
|
53
|
-
* - Other errors are preserved.
|
|
54
24
|
* @param {object} error - Original Error
|
|
55
25
|
* @returns {object} A new Error
|
|
56
26
|
*/
|
|
@@ -59,25 +29,7 @@ export const mapAiError = error => {
|
|
|
59
29
|
return error;
|
|
60
30
|
}
|
|
61
31
|
|
|
62
|
-
|
|
63
|
-
// This re-creates the error with a better message, making it easier to debug.
|
|
64
|
-
if ( NoObjectGeneratedError.isInstance( error ) && error.message.includes( NO_OBJECT_SCHEMA_MISMATCH_MESSAGE ) ) {
|
|
65
|
-
const zodError = findInstanceInCauseChain( error, 'ZodError' );
|
|
66
|
-
if ( zodError && zodError.issues?.length > 0 ) {
|
|
67
|
-
const [ { path, message } ] = zodError.issues;
|
|
68
|
-
return new NoObjectGeneratedError( {
|
|
69
|
-
message: `${error.message} First issue is "${message}" at path [${path.join( ', ' )}].`,
|
|
70
|
-
cause: error.cause,
|
|
71
|
-
text: error.text,
|
|
72
|
-
response: error.response,
|
|
73
|
-
usage: error.usage,
|
|
74
|
-
finishReason: error.finishReason
|
|
75
|
-
} );
|
|
76
|
-
}
|
|
77
|
-
return error;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const isApiError = APICallError.isInstance( error );
|
|
32
|
+
const isApiError = ai.APICallError.isInstance( error );
|
|
81
33
|
const isGrammarCompilationError = error.message === 'Grammar compilation timed out.';
|
|
82
34
|
|
|
83
35
|
// This error is actually transient, so instead of FatalError, return it
|
|
@@ -85,45 +37,13 @@ export const mapAiError = error => {
|
|
|
85
37
|
return error;
|
|
86
38
|
}
|
|
87
39
|
|
|
40
|
+
// Non-retryable API failures are already classified by AI SDK as permanent provider failures.
|
|
88
41
|
if ( isApiError && !error.isRetryable ) {
|
|
89
|
-
|
|
90
|
-
return toFatalError( error, error.statusCode ? `HTTP ${error.statusCode}` : '' );
|
|
91
|
-
}
|
|
92
|
-
if ( InvalidArgumentError.isInstance( error ) ) {
|
|
93
|
-
// Invalid call settings are deterministic caller bugs, so retrying the same activity cannot fix them.
|
|
94
|
-
return toFatalError( error );
|
|
95
|
-
}
|
|
96
|
-
if ( InvalidDataContentError.isInstance( error ) ) {
|
|
97
|
-
// Invalid media content has the wrong local shape/encoding and will fail again with the same input.
|
|
98
|
-
return toFatalError( error );
|
|
99
|
-
}
|
|
100
|
-
if ( InvalidPromptError.isInstance( error ) ) {
|
|
101
|
-
// Invalid prompt structure is a deterministic request-construction error.
|
|
102
|
-
return toFatalError( error );
|
|
42
|
+
return new TransparentFatalError( error );
|
|
103
43
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
if ( LoadSettingError.isInstance( error ) ) {
|
|
109
|
-
// Missing or invalid provider settings are deployment/configuration problems.
|
|
110
|
-
return toFatalError( error );
|
|
111
|
-
}
|
|
112
|
-
if ( NoImageGeneratedError.isInstance( error ) ) {
|
|
113
|
-
// Image generation completed provider calls but collected zero images; repeating identical input is not useful.
|
|
114
|
-
return toFatalError( error );
|
|
115
|
-
}
|
|
116
|
-
if ( NoSuchProviderError.isInstance( error ) ) {
|
|
117
|
-
// A missing provider id is a deterministic provider registry/configuration error.
|
|
118
|
-
return toFatalError( error );
|
|
119
|
-
}
|
|
120
|
-
if ( NoSuchModelError.isInstance( error ) ) {
|
|
121
|
-
// A missing model id is a deterministic provider/model configuration error.
|
|
122
|
-
return toFatalError( error );
|
|
123
|
-
}
|
|
124
|
-
if ( UnsupportedFunctionalityError.isInstance( error ) ) {
|
|
125
|
-
// The selected model/output mode does not support the requested feature.
|
|
126
|
-
return toFatalError( error );
|
|
44
|
+
|
|
45
|
+
if ( nonRetryableAiSdkErrorTypes.some( E => E.isInstance( error ) ) ) {
|
|
46
|
+
return new TransparentFatalError( error );
|
|
127
47
|
}
|
|
128
48
|
return error;
|
|
129
49
|
};
|
|
@@ -19,8 +19,8 @@ import {
|
|
|
19
19
|
ToolCallRepairError,
|
|
20
20
|
UnsupportedFunctionalityError
|
|
21
21
|
} from 'ai';
|
|
22
|
-
import { FatalError } from '@outputai/core';
|
|
23
|
-
import {
|
|
22
|
+
import { FatalError, TransparentFatalError } from '@outputai/core';
|
|
23
|
+
import { mapAiError } from './error_handler.js';
|
|
24
24
|
|
|
25
25
|
const makeApiCallError = ( input = {} ) => new APICallError( {
|
|
26
26
|
message: 'Provider rejected the request',
|
|
@@ -130,63 +130,6 @@ const preservedAiSdkErrors = [
|
|
|
130
130
|
]
|
|
131
131
|
];
|
|
132
132
|
|
|
133
|
-
describe( 'findInstanceInCauseChain', () => {
|
|
134
|
-
class FirstCustomError extends Error {}
|
|
135
|
-
class SecondCustomError extends Error {}
|
|
136
|
-
|
|
137
|
-
it( 'returns the input error when it matches the target constructor', () => {
|
|
138
|
-
const error = new FirstCustomError( 'first' );
|
|
139
|
-
|
|
140
|
-
expect( findInstanceInCauseChain( error, FirstCustomError ) ).toBe( error );
|
|
141
|
-
} );
|
|
142
|
-
|
|
143
|
-
it( 'returns the input error when it matches the target constructor name', () => {
|
|
144
|
-
const error = new FirstCustomError( 'first' );
|
|
145
|
-
|
|
146
|
-
expect( findInstanceInCauseChain( error, 'FirstCustomError' ) ).toBe( error );
|
|
147
|
-
} );
|
|
148
|
-
|
|
149
|
-
it( 'walks the cause chain to find an error by constructor', () => {
|
|
150
|
-
const target = new SecondCustomError( 'second' );
|
|
151
|
-
const wrapper = new FirstCustomError( 'first', { cause: target } );
|
|
152
|
-
|
|
153
|
-
expect( findInstanceInCauseChain( wrapper, SecondCustomError ) ).toBe( target );
|
|
154
|
-
} );
|
|
155
|
-
|
|
156
|
-
it( 'walks the cause chain to find an error by constructor name', () => {
|
|
157
|
-
const target = new SecondCustomError( 'second' );
|
|
158
|
-
const wrapper = new FirstCustomError( 'first', { cause: target } );
|
|
159
|
-
|
|
160
|
-
expect( findInstanceInCauseChain( wrapper, 'SecondCustomError' ) ).toBe( target );
|
|
161
|
-
} );
|
|
162
|
-
|
|
163
|
-
it( 'returns null when the target is not found', () => {
|
|
164
|
-
const error = new FirstCustomError( 'first', { cause: new Error( 'root' ) } );
|
|
165
|
-
|
|
166
|
-
expect( findInstanceInCauseChain( error, SecondCustomError ) ).toBeNull();
|
|
167
|
-
} );
|
|
168
|
-
|
|
169
|
-
it( 'returns null for empty or non-object inputs', () => {
|
|
170
|
-
expect( findInstanceInCauseChain( null, Error ) ).toBeNull();
|
|
171
|
-
expect( findInstanceInCauseChain( 'not an error', Error ) ).toBeNull();
|
|
172
|
-
} );
|
|
173
|
-
|
|
174
|
-
it( 'returns null for object causes without constructors', () => {
|
|
175
|
-
const cause = Object.create( null );
|
|
176
|
-
const error = new FirstCustomError( 'first', { cause } );
|
|
177
|
-
|
|
178
|
-
expect( findInstanceInCauseChain( error, 'SecondCustomError' ) ).toBeNull();
|
|
179
|
-
} );
|
|
180
|
-
|
|
181
|
-
it( 'stops searching after the depth limit', () => {
|
|
182
|
-
const makeErrorChain = depth => depth === 0 ?
|
|
183
|
-
new SecondCustomError( 'target' ) :
|
|
184
|
-
new FirstCustomError( `level ${depth}`, { cause: makeErrorChain( depth - 1 ) } );
|
|
185
|
-
|
|
186
|
-
expect( findInstanceInCauseChain( makeErrorChain( 11 ), SecondCustomError ) ).toBeNull();
|
|
187
|
-
} );
|
|
188
|
-
} );
|
|
189
|
-
|
|
190
133
|
describe( 'mapAiError', () => {
|
|
191
134
|
it( 'preserves existing FatalError instances', () => {
|
|
192
135
|
const error = new FatalError( 'Already fatal' );
|
|
@@ -194,7 +137,7 @@ describe( 'mapAiError', () => {
|
|
|
194
137
|
expect( mapAiError( error ) ).toBe( error );
|
|
195
138
|
} );
|
|
196
139
|
|
|
197
|
-
it( '
|
|
140
|
+
it( 'preserves NoObjectGeneratedError with its complete schema issue details', () => {
|
|
198
141
|
class ZodError extends Error {
|
|
199
142
|
constructor( issues ) {
|
|
200
143
|
super( 'schema failed' );
|
|
@@ -217,32 +160,12 @@ describe( 'mapAiError', () => {
|
|
|
217
160
|
cause: validationError
|
|
218
161
|
} );
|
|
219
162
|
|
|
220
|
-
const result = mapAiError( error );
|
|
221
|
-
|
|
222
|
-
expect( result ).not.toBe( error );
|
|
223
|
-
expect( NoObjectGeneratedError.isInstance( result ) ).toBe( true );
|
|
224
|
-
expect( result.name ).toBe( 'AI_NoObjectGeneratedError' );
|
|
225
|
-
expect( result.message ).toBe(
|
|
226
|
-
'No object generated: response did not match schema. First issue is "Expected string" at path [items, 0, title].'
|
|
227
|
-
);
|
|
228
|
-
expect( result.cause ).toBe( validationError );
|
|
229
|
-
expect( result.text ).toBe( error.text );
|
|
230
|
-
expect( result.response ).toBe( error.response );
|
|
231
|
-
expect( result.usage ).toBe( error.usage );
|
|
232
|
-
expect( result.finishReason ).toBe( error.finishReason );
|
|
233
|
-
} );
|
|
234
|
-
|
|
235
|
-
it( 'preserves NoObjectGeneratedError schema mismatches when no schema issue is available', () => {
|
|
236
|
-
const error = new NoObjectGeneratedError( {
|
|
237
|
-
message: 'No object generated: response did not match schema.',
|
|
238
|
-
text: '{"items":[{}]}',
|
|
239
|
-
cause: new Error( 'validation failed' )
|
|
240
|
-
} );
|
|
241
|
-
|
|
242
163
|
expect( mapAiError( error ) ).toBe( error );
|
|
164
|
+
expect( error.cause.cause ).toBe( zodError );
|
|
165
|
+
expect( error.cause.cause.issues ).toEqual( zodError.issues );
|
|
243
166
|
} );
|
|
244
167
|
|
|
245
|
-
it( 'maps non-retryable APICallError instances to
|
|
168
|
+
it( 'maps non-retryable APICallError instances to TransparentFatalError', () => {
|
|
246
169
|
const error = makeApiCallError( {
|
|
247
170
|
statusCode: 400,
|
|
248
171
|
isRetryable: false
|
|
@@ -250,20 +173,18 @@ describe( 'mapAiError', () => {
|
|
|
250
173
|
|
|
251
174
|
const result = mapAiError( error );
|
|
252
175
|
|
|
253
|
-
expect( result ).toBeInstanceOf(
|
|
254
|
-
expect( result.message ).toBe( 'AI-SDK fatal error (HTTP 400): Provider rejected the request' );
|
|
176
|
+
expect( result ).toBeInstanceOf( TransparentFatalError );
|
|
255
177
|
expect( result.cause ).toBe( error );
|
|
256
178
|
} );
|
|
257
179
|
|
|
258
|
-
it( 'maps non-retryable APICallError instances without status codes to
|
|
180
|
+
it( 'maps non-retryable APICallError instances without status codes to TransparentFatalError', () => {
|
|
259
181
|
const error = makeApiCallError( {
|
|
260
182
|
isRetryable: false
|
|
261
183
|
} );
|
|
262
184
|
|
|
263
185
|
const result = mapAiError( error );
|
|
264
186
|
|
|
265
|
-
expect( result ).toBeInstanceOf(
|
|
266
|
-
expect( result.message ).toBe( 'AI-SDK fatal error: Provider rejected the request' );
|
|
187
|
+
expect( result ).toBeInstanceOf( TransparentFatalError );
|
|
267
188
|
expect( result.cause ).toBe( error );
|
|
268
189
|
} );
|
|
269
190
|
|
|
@@ -286,13 +207,12 @@ describe( 'mapAiError', () => {
|
|
|
286
207
|
expect( mapAiError( error ) ).toBe( error );
|
|
287
208
|
} );
|
|
288
209
|
|
|
289
|
-
it.each( fatalAiSdkErrors )( 'maps %s to
|
|
210
|
+
it.each( fatalAiSdkErrors )( 'maps %s to TransparentFatalError', ( _name, makeError ) => {
|
|
290
211
|
const error = makeError();
|
|
291
212
|
|
|
292
213
|
const result = mapAiError( error );
|
|
293
214
|
|
|
294
|
-
expect( result ).toBeInstanceOf(
|
|
295
|
-
expect( result.message ).toBe( `AI-SDK fatal error: ${error.message}` );
|
|
215
|
+
expect( result ).toBeInstanceOf( TransparentFatalError );
|
|
296
216
|
expect( result.cause ).toBe( error );
|
|
297
217
|
} );
|
|
298
218
|
|
|
@@ -12,14 +12,15 @@ import { calculateBase64FileSize } from './image.js';
|
|
|
12
12
|
*
|
|
13
13
|
* @param {object} args
|
|
14
14
|
* @param {string} args.traceId - id created by the startTrace
|
|
15
|
+
* @param {string} args.providerId - id of the provider used
|
|
15
16
|
* @param {string} args.modelId - id of the model used
|
|
16
17
|
* @param {object} args.response - AI SDK's text response
|
|
17
18
|
* @returns {object} Proxied response
|
|
18
19
|
*/
|
|
19
|
-
export const wrapTextResponse = async ( { traceId, modelId, response } ) => {
|
|
20
|
+
export const wrapTextResponse = async ( { traceId, providerId, modelId, response } ) => {
|
|
20
21
|
const { totalUsage: usage, providerMetadata, text: result, steps, sources } = response;
|
|
21
22
|
|
|
22
|
-
const cost = await calculateLLMCallCost( { usage, modelId } );
|
|
23
|
+
const cost = await calculateLLMCallCost( { usage, modelId, providerId } );
|
|
23
24
|
const sourcesFromTools = extractSourcesFromSteps( steps );
|
|
24
25
|
|
|
25
26
|
endTraceWithSuccess( { traceId, usage, cost, result, providerMetadata, sourcesFromTools } );
|
|
@@ -48,13 +49,14 @@ export const wrapTextResponse = async ( { traceId, modelId, response } ) => {
|
|
|
48
49
|
*
|
|
49
50
|
* @param {object} args
|
|
50
51
|
* @param {string} args.traceId - id created by the startTrace
|
|
52
|
+
* @param {string} args.providerId - id of the provider used
|
|
51
53
|
* @param {string} args.modelId - id of the model used
|
|
52
54
|
* @param {Function} args.onFinish - Original callback to call with the proxied response
|
|
53
55
|
* @returns {object} Proxied response
|
|
54
56
|
*/
|
|
55
|
-
export const wrapStreamOnFinishResponse = ( { traceId, modelId, onFinish: _onFinish } ) => ( {
|
|
57
|
+
export const wrapStreamOnFinishResponse = ( { traceId, providerId, modelId, onFinish: _onFinish } ) => ( {
|
|
56
58
|
async onFinish( response ) {
|
|
57
|
-
const proxiedResponse = await wrapTextResponse( { traceId, modelId, response } );
|
|
59
|
+
const proxiedResponse = await wrapTextResponse( { traceId, providerId, modelId, response } );
|
|
58
60
|
_onFinish?.( proxiedResponse );
|
|
59
61
|
}
|
|
60
62
|
} );
|
|
@@ -68,13 +70,14 @@ export const wrapStreamOnFinishResponse = ( { traceId, modelId, onFinish: _onFin
|
|
|
68
70
|
*
|
|
69
71
|
* @param {object} args
|
|
70
72
|
* @param {string} args.traceId - id created by the startTrace
|
|
73
|
+
* @param {string} args.providerId - id of the provider used
|
|
71
74
|
* @param {string} args.modelId - id of the model used
|
|
72
75
|
* @param {object} args.response - AI SDK's image response
|
|
73
76
|
* @returns {object} Proxied response
|
|
74
77
|
*/
|
|
75
|
-
export const wrapImageResponse = async ( { traceId, modelId, response } ) => {
|
|
78
|
+
export const wrapImageResponse = async ( { traceId, providerId, modelId, response } ) => {
|
|
76
79
|
const { usage, providerMetadata } = response;
|
|
77
|
-
const cost = await calculateLLMCallCost( { usage, modelId } );
|
|
80
|
+
const cost = await calculateLLMCallCost( { usage, providerId, modelId } );
|
|
78
81
|
|
|
79
82
|
const result = response.images.map( ( { mediaType, base64 } ) => ( {
|
|
80
83
|
size: calculateBase64FileSize( base64 ),
|
|
@@ -52,6 +52,7 @@ const makeAiSdkImageResponse = () => generateImage( {
|
|
|
52
52
|
|
|
53
53
|
describe( 'wrapTextResponse', () => {
|
|
54
54
|
const traceId = 'trace-1';
|
|
55
|
+
const providerId = 'openai';
|
|
55
56
|
const modelId = 'test-model';
|
|
56
57
|
const mockCost = { total: 0.001, components: [ { name: 'input_tokens', value: 0.001 } ] };
|
|
57
58
|
|
|
@@ -65,13 +66,14 @@ describe( 'wrapTextResponse', () => {
|
|
|
65
66
|
it( 'uses a text response fixture to calculate cost, end trace, and attach cost', async () => {
|
|
66
67
|
const response = clone( textResponseFixture );
|
|
67
68
|
|
|
68
|
-
const wrapped = await wrapTextResponse( { traceId, modelId, response } );
|
|
69
|
+
const wrapped = await wrapTextResponse( { traceId, providerId, modelId, response } );
|
|
69
70
|
|
|
70
71
|
expect( wrapped.result ).toBe( response.text );
|
|
71
72
|
expect( wrapped.cost ).toEqual( mockCost );
|
|
72
73
|
expect( mocks.calculateLLMCallCost ).toHaveBeenCalledWith( {
|
|
73
74
|
usage: response.totalUsage,
|
|
74
|
-
modelId
|
|
75
|
+
modelId,
|
|
76
|
+
providerId
|
|
75
77
|
} );
|
|
76
78
|
expect( mocks.extractSourcesFromSteps ).toHaveBeenCalledWith( response.steps );
|
|
77
79
|
expect( mocks.endTraceWithSuccess ).toHaveBeenCalledWith( {
|
|
@@ -92,7 +94,7 @@ describe( 'wrapTextResponse', () => {
|
|
|
92
94
|
response.sources = nativeSources;
|
|
93
95
|
mocks.extractSourcesFromSteps.mockReturnValue( [] );
|
|
94
96
|
|
|
95
|
-
const wrapped = await wrapTextResponse( { traceId, modelId, response } );
|
|
97
|
+
const wrapped = await wrapTextResponse( { traceId, providerId, modelId, response } );
|
|
96
98
|
|
|
97
99
|
expect( wrapped.sources ).toBe( nativeSources );
|
|
98
100
|
expect( mocks.combineSources ).not.toHaveBeenCalled();
|
|
@@ -119,7 +121,7 @@ describe( 'wrapTextResponse', () => {
|
|
|
119
121
|
mocks.extractSourcesFromSteps.mockReturnValue( [ toolSource ] );
|
|
120
122
|
mocks.combineSources.mockReturnValue( mergedSources );
|
|
121
123
|
|
|
122
|
-
const wrapped = await wrapTextResponse( { traceId, modelId, response } );
|
|
124
|
+
const wrapped = await wrapTextResponse( { traceId, providerId, modelId, response } );
|
|
123
125
|
|
|
124
126
|
expect( wrapped.sources ).toBe( mergedSources );
|
|
125
127
|
expect( mocks.combineSources ).toHaveBeenCalledWith( {
|
|
@@ -131,6 +133,7 @@ describe( 'wrapTextResponse', () => {
|
|
|
131
133
|
|
|
132
134
|
describe( 'wrapStreamOnFinishResponse', () => {
|
|
133
135
|
const traceId = 'stream-trace';
|
|
136
|
+
const providerId = 'openai';
|
|
134
137
|
const modelId = 'stream-model';
|
|
135
138
|
const mockCost = { total: 0.002, components: [] };
|
|
136
139
|
|
|
@@ -146,6 +149,7 @@ describe( 'wrapStreamOnFinishResponse', () => {
|
|
|
146
149
|
|
|
147
150
|
const callbacks = wrapStreamOnFinishResponse( {
|
|
148
151
|
traceId,
|
|
152
|
+
providerId,
|
|
149
153
|
modelId,
|
|
150
154
|
onFinish: userOnFinish
|
|
151
155
|
} );
|
|
@@ -173,6 +177,7 @@ describe( 'wrapStreamOnFinishResponse', () => {
|
|
|
173
177
|
|
|
174
178
|
const callbacks = wrapStreamOnFinishResponse( {
|
|
175
179
|
traceId,
|
|
180
|
+
providerId,
|
|
176
181
|
modelId
|
|
177
182
|
} );
|
|
178
183
|
|
|
@@ -188,13 +193,15 @@ describe( 'wrapStreamOnFinishResponse', () => {
|
|
|
188
193
|
} );
|
|
189
194
|
expect( mocks.calculateLLMCallCost ).toHaveBeenCalledWith( {
|
|
190
195
|
usage: response.totalUsage,
|
|
191
|
-
modelId
|
|
196
|
+
modelId,
|
|
197
|
+
providerId
|
|
192
198
|
} );
|
|
193
199
|
} );
|
|
194
200
|
} );
|
|
195
201
|
|
|
196
202
|
describe( 'wrapImageResponse', () => {
|
|
197
203
|
const traceId = 'image-trace';
|
|
204
|
+
const providerId = 'openai';
|
|
198
205
|
const modelId = 'image-model';
|
|
199
206
|
const mockCost = { total: 0.003, components: [] };
|
|
200
207
|
|
|
@@ -207,13 +214,14 @@ describe( 'wrapImageResponse', () => {
|
|
|
207
214
|
it( 'uses an image response fixture to trace image metadata and attach cost', async () => {
|
|
208
215
|
const response = await makeAiSdkImageResponse();
|
|
209
216
|
|
|
210
|
-
const wrapped = await wrapImageResponse( { traceId, modelId, response } );
|
|
217
|
+
const wrapped = await wrapImageResponse( { traceId, providerId, modelId, response } );
|
|
211
218
|
|
|
212
219
|
expect( wrapped.result ).toBe( response.images[0] );
|
|
213
220
|
expect( wrapped.cost ).toEqual( mockCost );
|
|
214
221
|
expect( mocks.calculateLLMCallCost ).toHaveBeenCalledWith( {
|
|
215
222
|
usage: response.usage,
|
|
216
|
-
modelId
|
|
223
|
+
modelId,
|
|
224
|
+
providerId
|
|
217
225
|
} );
|
|
218
226
|
expect( mocks.calculateBase64FileSize ).toHaveBeenCalledWith( response.images[0].base64 );
|
|
219
227
|
expect( mocks.endTraceWithSuccess ).toHaveBeenCalledWith( {
|