Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app/server/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const env = createEnv({
GROQ_API_KEY: z.string().optional(),
XAI_API_KEY: z.string().optional(),
OPENROUTER_API_KEY: z.string().optional(),
VERCEL_AI_GATEWAY_API_KEY: z.string().optional(),
TAVILY_API_KEY: z.string().optional(),
E2B_API_KEY: z.string().optional(),
GOOGLE_SERVICE_ACCOUNT_KEY_ENCODED: z.string().optional(),
Expand Down
34 changes: 34 additions & 0 deletions packages/app/server/src/providers/ProviderFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
VertexAIProvider,
PROXY_PASSTHROUGH_ONLY_MODEL as VertexAIProxyPassthroughOnlyModel,
} from './VertexAIProvider';
import { VercelAIGatewayProvider } from './VercelAIGatewayProvider';

/**
* Creates model-to-provider mapping from the model_prices_and_context_window.json file.
Expand Down Expand Up @@ -58,6 +59,11 @@ const createChatModelToProviderMapping = (): Record<string, ProviderType> => {
case 'Xai':
mapping[modelConfig.model_id] = ProviderType.XAI;
break;
case 'VercelAIGateway':
case 'Vercel AI Gateway':
case 'Vercel':
mapping[modelConfig.model_id] = ProviderType.VERCEL_AI_GATEWAY;
break;
// Add other providers as needed
default:
// Skip models with unsupported providers
Expand Down Expand Up @@ -165,6 +171,32 @@ export const getProvider = (
type = ProviderType.GEMINI_GPT;
}

if (
(completionPath.includes('audio/transcriptions') ||
completionPath.includes('audio/speech')) &&
model.includes('/')
) {
type = ProviderType.VERCEL_AI_GATEWAY;
}

if (type === undefined && model.includes('/')) {
const [providerPrefix] = model.split('/');
const supportedPrefixes = [
'openai',
'anthropic',
'google',
'gemini',
'groq',
'xai',
'cohere',
'mistral',
'perplexity',
];
if (supportedPrefixes.includes(providerPrefix.toLowerCase())) {
type = ProviderType.VERCEL_AI_GATEWAY;
}
}

switch (type) {
case ProviderType.GPT:
return new GPTProvider(stream, model);
Expand Down Expand Up @@ -192,6 +224,8 @@ export const getProvider = (
return new GroqProvider(stream, model);
case ProviderType.XAI:
return new XAIProvider(stream, model);
case ProviderType.VERCEL_AI_GATEWAY:
return new VercelAIGatewayProvider(stream, model);
default:
throw new Error(`Unknown provider type: ${type}`);
}
Expand Down
1 change: 1 addition & 0 deletions packages/app/server/src/providers/ProviderType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export enum ProviderType {
OPENAI_VIDEOS = 'OPENAI_VIDEOS',
GROQ = 'GROQ',
XAI = 'XAI',
VERCEL_AI_GATEWAY = 'VERCEL_AI_GATEWAY',
}
187 changes: 187 additions & 0 deletions packages/app/server/src/providers/VercelAIGatewayProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { LlmTransactionMetadata, Transaction } from '../types';
import { getCostPerToken, getModelPrice, isValidModel } from '../services/AccountingService';
import { BaseProvider } from './BaseProvider';
import { ProviderType } from './ProviderType';
import logger from '../logger';
import { env } from '../env';
import { parseSSEGPTFormat, type CompletionStateBody } from './GPTProvider';
import { Decimal } from '@prisma/client/runtime/library';

export class VercelAIGatewayProvider extends BaseProvider {
private readonly VERCEL_AI_GATEWAY_BASE_URL = 'https://ai-gateway.vercel.sh/v1';

getType(): ProviderType {
return ProviderType.VERCEL_AI_GATEWAY;
}

getBaseUrl(): string {
return this.VERCEL_AI_GATEWAY_BASE_URL;
}

getApiKey(): string | undefined {
return env.VERCEL_AI_GATEWAY_API_KEY;
}

async handleBody(
data: string,
requestBody?: Record<string, unknown>
): Promise<Transaction> {
try {
const model = this.getModel().toLowerCase();
const isTranscriptionModel = model.includes('whisper') || model.includes('transcription');
const isSpeechModel = model.includes('tts') || model.includes('speech');

let isTranscriptionResponse = false;
try {
const parsed = JSON.parse(data);
if (parsed.text !== undefined && typeof parsed.text === 'string') {
isTranscriptionResponse = true;
}
} catch {
}

if (isTranscriptionModel || isTranscriptionResponse) {
return this.handleAudioResponse(data, requestBody, 'transcription');
}

if (isSpeechModel) {
return this.handleAudioResponse(data, requestBody, 'speech');
}

return this.handleChatCompletionResponse(data);
} catch (error) {
logger.error(`Error processing Vercel AI Gateway response: ${error}`);
throw error;
}
}

private handleChatCompletionResponse(data: string): Transaction {
let prompt_tokens = 0;
let completion_tokens = 0;
let total_tokens = 0;
let providerId = 'null';

if (this.getIsStream()) {
const chunks = parseSSEGPTFormat(data);

for (const chunk of chunks) {
if (chunk.usage && chunk.usage !== null) {
prompt_tokens += chunk.usage.prompt_tokens;
completion_tokens += chunk.usage.completion_tokens;
total_tokens += chunk.usage.total_tokens;
}
providerId = chunk.id || 'null';
}
} else {
const parsed = JSON.parse(data) as CompletionStateBody;
prompt_tokens += parsed.usage.prompt_tokens;
completion_tokens += parsed.usage.completion_tokens;
total_tokens += parsed.usage.total_tokens;
providerId = parsed.id || 'null';
}

const cost = getCostPerToken(
this.getModel(),
prompt_tokens,
completion_tokens
);
Comment on lines +83 to +87
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handleChatCompletionResponse method will throw an UnknownModelError when processing responses for provider-prefixed models (e.g., "openai/gpt-4", "anthropic/claude-3") because it attempts to look up pricing for the full prefixed model name, which doesn't exist in the pricing database.

View Details
📝 Patch Details
diff --git a/packages/app/server/src/providers/VercelAIGatewayProvider.ts b/packages/app/server/src/providers/VercelAIGatewayProvider.ts
index aefc3062..39bf3e5f 100644
--- a/packages/app/server/src/providers/VercelAIGatewayProvider.ts
+++ b/packages/app/server/src/providers/VercelAIGatewayProvider.ts
@@ -10,6 +10,19 @@ import { Decimal } from '@prisma/client/runtime/library';
 export class VercelAIGatewayProvider extends BaseProvider {
   private readonly VERCEL_AI_GATEWAY_BASE_URL = 'https://ai-gateway.vercel.sh/v1';
 
+  /**
+   * Extract base model name from provider-prefixed model (e.g., "openai/gpt-4" -> "gpt-4")
+   * Returns the original model if no prefix is found
+   */
+  private getBaseModelName(): string {
+    const model = this.getModel();
+    if (model.includes('/')) {
+      const [, ...modelParts] = model.split('/');
+      return modelParts.join('/');
+    }
+    return model;
+  }
+
   getType(): ProviderType {
     return ProviderType.VERCEL_AI_GATEWAY;
   }
@@ -81,7 +94,7 @@ export class VercelAIGatewayProvider extends BaseProvider {
     }
 
     const cost = getCostPerToken(
-      this.getModel(),
+      this.getBaseModelName(),
       prompt_tokens,
       completion_tokens
     );
@@ -110,17 +123,18 @@ export class VercelAIGatewayProvider extends BaseProvider {
     let cost = new Decimal(0);
     let metadata: LlmTransactionMetadata;
     const model = this.getModel();
+    const baseModel = this.getBaseModelName();
 
-    const modelPrice = getModelPrice(model);
+    const modelPrice = getModelPrice(baseModel);
     
     if (endpointType === 'transcription') {
       try {
         const transcriptionData = JSON.parse(data);
         const text = transcriptionData.text || '';
         
-        if (modelPrice && isValidModel(model)) {
+        if (modelPrice && isValidModel(baseModel)) {
           const textTokens = Math.ceil(text.length / 4);
-          cost = getCostPerToken(model, 0, textTokens);
+          cost = getCostPerToken(baseModel, 0, textTokens);
         } else {
           cost = new Decimal(0.01);
         }
@@ -135,7 +149,7 @@ export class VercelAIGatewayProvider extends BaseProvider {
         };
       } catch (error) {
         logger.error(`Error parsing transcription response: ${error}`);
-        cost = modelPrice && isValidModel(model) ? new Decimal(0) : new Decimal(0.01);
+        cost = modelPrice && isValidModel(baseModel) ? new Decimal(0) : new Decimal(0.01);
         metadata = {
           providerId: 'transcription',
           provider: this.getType(),
@@ -149,9 +163,9 @@ export class VercelAIGatewayProvider extends BaseProvider {
       const inputText = (requestBody?.input as string) || '';
       const characterCount = inputText.length;
       
-      if (modelPrice && isValidModel(model)) {
+      if (modelPrice && isValidModel(baseModel)) {
         const inputTokens = Math.ceil(characterCount / 4);
-        cost = getCostPerToken(model, inputTokens, 0);
+        cost = getCostPerToken(baseModel, inputTokens, 0);
       } else {
         const costPerCharacter = new Decimal(0.000015);
         cost = costPerCharacter.mul(characterCount);
@@ -166,7 +180,7 @@ export class VercelAIGatewayProvider extends BaseProvider {
         totalTokens: characterCount,
       };
     } else {
-      cost = modelPrice && isValidModel(model) ? new Decimal(0) : new Decimal(0.01);
+      cost = modelPrice && isValidModel(baseModel) ? new Decimal(0) : new Decimal(0.01);
       metadata = {
         providerId: 'audio',
         provider: this.getType(),
@@ -184,4 +198,3 @@ export class VercelAIGatewayProvider extends BaseProvider {
     };
   }
 }
-

Analysis

VercelAIGatewayProvider throws UnknownModelError for provider-prefixed models

What fails: VercelAIGatewayProvider.handleChatCompletionResponse() calls getCostPerToken(this.getModel(), ...) with provider-prefixed model names like "openai/gpt-4", but pricing database only contains base model names like "gpt-4"

How to reproduce:

// Create provider with prefixed model and process a chat completion response
const provider = new VercelAIGatewayProvider(false, 'openai/gpt-4');
const mockResponse = JSON.stringify({
  usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 }
});
provider.handleBody(mockResponse); // Throws UnknownModelError

Result: getCostPerToken() calls isValidModel("openai/gpt-4") which returns false, then throws UnknownModelError: Invalid model: openai/gpt-4

Expected: Should extract base model "gpt-4" for pricing lookup per Vercel AI Gateway docs which confirm "creator/model-name" format is correct

Note: Same issue affects handleAudioResponse() method for transcription and speech endpoints


const metadata: LlmTransactionMetadata = {
providerId: providerId,
provider: this.getType(),
model: this.getModel(),
inputTokens: prompt_tokens,
outputTokens: completion_tokens,
totalTokens: total_tokens,
};

return {
rawTransactionCost: cost,
metadata: metadata,
status: 'success',
};
}

private handleAudioResponse(
data: string,
requestBody: Record<string, unknown> | undefined,
endpointType: 'transcription' | 'speech'
): Transaction {
let cost = new Decimal(0);
let metadata: LlmTransactionMetadata;
const model = this.getModel();

const modelPrice = getModelPrice(model);

if (endpointType === 'transcription') {
try {
const transcriptionData = JSON.parse(data);
const text = transcriptionData.text || '';

if (modelPrice && isValidModel(model)) {
const textTokens = Math.ceil(text.length / 4);
cost = getCostPerToken(model, 0, textTokens);
} else {
cost = new Decimal(0.01);
}

metadata = {
providerId: 'transcription',
provider: this.getType(),
model: model,
inputTokens: 0,
outputTokens: text.length,
totalTokens: text.length,
};
} catch (error) {
logger.error(`Error parsing transcription response: ${error}`);
cost = modelPrice && isValidModel(model) ? new Decimal(0) : new Decimal(0.01);
metadata = {
providerId: 'transcription',
provider: this.getType(),
model: model,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
};
}
} else if (endpointType === 'speech') {
const inputText = (requestBody?.input as string) || '';
const characterCount = inputText.length;

if (modelPrice && isValidModel(model)) {
const inputTokens = Math.ceil(characterCount / 4);
cost = getCostPerToken(model, inputTokens, 0);
} else {
const costPerCharacter = new Decimal(0.000015);
cost = costPerCharacter.mul(characterCount);
}

metadata = {
providerId: 'speech',
provider: this.getType(),
model: model,
inputTokens: characterCount,
outputTokens: 0,
totalTokens: characterCount,
};
} else {
cost = modelPrice && isValidModel(model) ? new Decimal(0) : new Decimal(0.01);
metadata = {
providerId: 'audio',
provider: this.getType(),
model: model,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
};
}

return {
rawTransactionCost: cost,
metadata: metadata,
status: 'success',
};
}
}

6 changes: 6 additions & 0 deletions packages/sdk/react/src/hooks/useEchoModelProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
createEchoGroq,
createEchoOpenAI,
createEchoOpenRouter,
createEchoVercelAIGateway,
createEchoXAI,
} from '@merit-systems/echo-typescript-sdk';
import { useMemo } from 'react';
Expand Down Expand Up @@ -31,6 +32,11 @@ export const useEchoModelProviders = () => {
),
groq: createEchoGroq(baseConfig, getToken, onInsufficientFunds),
xai: createEchoXAI(baseConfig, getToken, onInsufficientFunds),
vercelAIGateway: createEchoVercelAIGateway(
baseConfig,
getToken,
onInsufficientFunds
),
};
}, [getToken, config.appId, config.baseRouterUrl, setIsInsufficientFunds]);
};
1 change: 1 addition & 0 deletions packages/sdk/ts/src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './groq';
export * from './xai';
export * from './openai';
export * from './openrouter';
export * from './vercel-ai-gateway';

export function echoFetch(
originalFetch: typeof fetch,
Expand Down
27 changes: 27 additions & 0 deletions packages/sdk/ts/src/providers/vercel-ai-gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {
createOpenAI as createOpenAIBase,
OpenAIProvider,
} from '@ai-sdk/openai';
import { ROUTER_BASE_URL } from 'config';
import { EchoConfig } from '../types';
import { validateAppId } from '../utils/validation';
import { echoFetch } from './index';

export function createEchoVercelAIGateway(
{ appId, baseRouterUrl = ROUTER_BASE_URL }: EchoConfig,
getTokenFn: (appId: string) => Promise<string | null>,
onInsufficientFunds?: () => void
): OpenAIProvider {
validateAppId(appId, 'createEchoVercelAIGateway');

return createOpenAIBase({
baseURL: baseRouterUrl,
apiKey: 'placeholder_replaced_by_echoFetch',
fetch: echoFetch(
fetch,
async () => await getTokenFn(appId),
onInsufficientFunds
),
});
}