@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/llm",
3
- "version": "0.10.1-next.fc0a41f.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.25.7",
18
- "undici": "8.5.0",
19
- "@outputai/core": "0.10.1-next.fc0a41f.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.111",
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
  } );
@@ -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
- bedrock: createAmazonBedrock,
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
 
@@ -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: 'bedrock', pkg: '@ai-sdk/amazon-bedrock', exportName: 'createAmazonBedrock' },
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
- 'bedrock',
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 } );
@@ -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 ) ).toEqual( cost );
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 ) ).toBeDefined();
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 ) ).toBeDefined();
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( 'noCost' ) ).toBeUndefined();
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.modelId - Name of the model, provider prefix is optional
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
 
@@ -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( new Map( [ [ 'gpt-4o', { input: 2, output: 10, cache_read: 1 } ] ] ) );
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( new Map( [ [ 'cached-model', { input: 4, cache_read: 1, output: 10 } ] ] ) );
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( new Map( [ [ 'no-cache', { input: 2, output: 10 } ] ] ) );
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( new Map( [ [ 'out-only', { output: 10 } ] ] ) );
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( new Map( [ [ 'in-only', { input: 1 } ] ] ) );
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( new Map( [ [
191
- 'with-reasoning',
192
- { input: 1, output: 10, reasoning: 60 }
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( new Map( [ [ 'no-reasoning', { input: 1, output: 10 } ] ] ) );
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( new Map( [ [
233
- 'full',
234
- { input: 2, output: 8, reasoning: 60 }
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( new Map( [ [ 'm', { input: 1, output: 2 } ] ] ) );
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
  } );
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Deprecated prompt `provider` spellings → canonical shipped names.
3
+ * Remove this module when the aliases are dropped.
4
+ */
5
+ export const deprecatedProviderAliases = {
6
+ vertex: 'google-vertex',
7
+ bedrock: 'amazon-bedrock'
8
+ };
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
- /** LLM provider (built-in: 'anthropic', 'openai', 'azure', 'vertex', 'bedrock', 'perplexity'; or any registered custom provider) */
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 */
@@ -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
- APICallError,
3
- InvalidArgumentError,
4
- InvalidDataContentError,
5
- InvalidPromptError,
6
- LoadAPIKeyError,
7
- LoadSettingError,
8
- NoImageGeneratedError,
9
- NoObjectGeneratedError,
10
- NoSuchModelError,
11
- NoSuchProviderError,
12
- UnsupportedFunctionalityError
13
- } from 'ai';
14
- import { FatalError } from '@outputai/core';
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
- * Map an AI SDK error to a framework specific error:
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
- // NoObjectGeneratedError can be thrown when the response doesn't match the schema.
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
- // Non-retryable API failures are already classified by AI SDK as permanent provider failures.
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
- if ( LoadAPIKeyError.isInstance( error ) ) {
105
- // Missing or invalid API key configuration will not change during an activity retry.
106
- return toFatalError( error );
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 { findInstanceInCauseChain, mapAiError } from './error_handler.js';
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( 'adds first schema issue details to NoObjectGeneratedError schema mismatches', () => {
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 FatalError', () => {
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( FatalError );
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 FatalError', () => {
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( FatalError );
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 FatalError', ( _name, makeError ) => {
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( FatalError );
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( {