Skip to content
Merged
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
91 changes: 91 additions & 0 deletions apps/web/src/lib/ai-gateway/processUsage.messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,97 @@ describe('processMessagesApiUsage', () => {
expect(result.cacheWriteTokens).toBe(0);
});

test('extracts is_byok=true from Vercel modelAttempts credentialType', () => {
const usage = {
input_tokens: 10,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
server_tool_use: { input_tokens: 0, web_fetch_requests: 0, web_search_requests: 0 },
};
const providerMetadata = {
gateway: {
routing: {
finalProvider: 'bedrock',
modelAttempts: [
{
success: true,
providerAttempts: [{ provider: 'bedrock', credentialType: 'byok', success: true }],
},
],
},
cost: '0',
marketCost: '0.000402',
},
};

const result = processMessagesApiUsage(usage, providerMetadata, coreProps);

expect(result.is_byok).toBe(true);
expect(result.cost_mUsd).toBe(402);
});

test('extracts is_byok=false from Vercel system credentialType', () => {
const usage = {
input_tokens: 10,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
server_tool_use: { input_tokens: 0, web_fetch_requests: 0, web_search_requests: 0 },
};
const providerMetadata = {
gateway: {
routing: {
finalProvider: 'bedrock',
modelAttempts: [
{
success: true,
providerAttempts: [{ provider: 'bedrock', credentialType: 'system', success: true }],
},
],
},
cost: '0',
marketCost: '0.000402',
},
};

const result = processMessagesApiUsage(usage, providerMetadata, coreProps);

expect(result.is_byok).toBe(false);
});

test('picks the successful provider attempt when earlier attempts failed', () => {
const usage = {
input_tokens: 10,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
server_tool_use: { input_tokens: 0, web_fetch_requests: 0, web_search_requests: 0 },
};
const providerMetadata = {
gateway: {
routing: {
finalProvider: 'anthropic',
modelAttempts: [
{
success: true,
providerAttempts: [
{ provider: 'bedrock', credentialType: 'byok', success: false },
{ provider: 'anthropic', credentialType: 'system', success: true },
],
},
],
},
cost: '0',
marketCost: '0.000402',
},
};

const result = processMessagesApiUsage(usage, providerMetadata, coreProps);

expect(result.is_byok).toBe(false);
});

test('returns zero cost when no usage or metadata is provided', () => {
const result = processMessagesApiUsage(null, null, coreProps);

Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/lib/ai-gateway/processUsage.messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
computeOpenRouterCostFields,
computeVercelCostMicrodollars,
drainSseStream,
extractVercelIsByok,
} from '@/lib/ai-gateway/processUsage.shared';
import type Anthropic from '@anthropic-ai/sdk';

Expand Down Expand Up @@ -61,7 +62,7 @@ export function processMessagesApiUsage(
cacheHitTokens,
cacheWriteTokens,
cost_mUsd,
is_byok: null,
is_byok: extractVercelIsByok(vercelGateway),
};
}

Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/lib/ai-gateway/processUsage.responses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,36 @@ describe('processResponsesApiUsage', () => {
expect(result.inputTokens).toBe(0);
expect(result.outputTokens).toBe(0);
});

test('extracts is_byok=true from Vercel modelAttempts credentialType', () => {
const usage = {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
input_tokens_details: { cached_tokens: 0 },
output_tokens_details: { reasoning_tokens: 0 },
};
const providerMetadata = {
gateway: {
routing: {
finalProvider: 'openai',
modelAttempts: [
{
success: true,
providerAttempts: [{ provider: 'openai', credentialType: 'byok', success: true }],
},
],
},
cost: '0',
marketCost: '0.0001',
},
};

const result = processResponsesApiUsage(usage, providerMetadata, coreProps);

expect(result.is_byok).toBe(true);
expect(result.cost_mUsd).toBe(100);
});
});

describe('parseMicrodollarUsageFromStream approval tests', () => {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/lib/ai-gateway/processUsage.responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
computeOpenRouterCostFields,
computeVercelCostMicrodollars,
drainSseStream,
extractVercelIsByok,
} from '@/lib/ai-gateway/processUsage.shared';

// OpenRouter adds cost fields to the standard Responses API usage object.
Expand Down Expand Up @@ -72,7 +73,7 @@ export function processResponsesApiUsage(
cacheHitTokens,
cacheWriteTokens: 0,
cost_mUsd,
is_byok: null,
is_byok: extractVercelIsByok(vercelGateway),
};
}

Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/lib/ai-gateway/processUsage.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,29 @@ export function computeVercelCostMicrodollars(
return toMicrodollars(isNaN(marketCost_USD) ? 0 : marketCost_USD);
}

/**
* Extracts whether the Vercel AI Gateway served the request with BYOK credentials.
*
* The gateway reports per-attempt `credentialType` ("byok" | "system") in
* `provider_metadata.gateway.routing.modelAttempts[].providerAttempts[]`. We look
* at the successful provider attempt within the successful model attempt. Returns
* `null` when credentialType is absent or no successful attempt is found.
*/
export function extractVercelIsByok(
vercelGateway: NonNullable<VercelProviderMetaData['gateway']> | undefined | null
): boolean | null {
const modelAttempts = vercelGateway?.routing?.modelAttempts;
if (!modelAttempts) return null;
const successfulModel = modelAttempts.find(m => m.success) ?? modelAttempts.at(-1);
const providerAttempts = successfulModel?.providerAttempts;
if (!providerAttempts) return null;
const successfulProvider = providerAttempts.find(p => p.success) ?? providerAttempts.at(-1);
const credentialType = successfulProvider?.credentialType;
if (credentialType === 'byok') return true;
if (credentialType === 'system') return false;
return null;
}

/**
* Drains a ReadableStream of binary chunks, calling `onTextChunk` for each
* decoded piece of text. Handles client-abort (`ResponseAborted`) gracefully
Expand Down
27 changes: 21 additions & 6 deletions apps/web/src/lib/ai-gateway/processUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import type {
OpenRouterUsage,
PromptInfo,
UsageMetaData,
VercelProviderMetaData,
} from '@/lib/ai-gateway/processUsage.types';
import {
parseResponsesMicrodollarUsageFromStream,
Expand All @@ -53,7 +54,11 @@ import {
parseMessagesMicrodollarUsageFromString,
} from '@/lib/ai-gateway/processUsage.messages';
import { OPENROUTER_BYOK_COST_MULTIPLIER } from '@/lib/ai-gateway/processUsage.constants';
import { computeOpenRouterCostFields, drainSseStream } from '@/lib/ai-gateway/processUsage.shared';
import {
computeOpenRouterCostFields,
drainSseStream,
extractVercelIsByok,
} from '@/lib/ai-gateway/processUsage.shared';
import { isClaudeModel } from '@/lib/ai-gateway/providers/anthropic.constants';
import { isMinimaxModel } from '@/lib/ai-gateway/providers/minimax';
import type { KiloExclusiveModel } from '@/lib/ai-gateway/providers/kilo-exclusive-model';
Expand Down Expand Up @@ -653,7 +658,8 @@ export function countAndStoreUsage(

export function processOpenRouterUsage(
usage: OpenRouterUsage | null | undefined,
coreProps: NotYetCostedUsageStats
coreProps: NotYetCostedUsageStats,
vercelProviderMetadata?: VercelProviderMetaData | null
): JustTheCostsUsageStats {
// usage may be null when there's no response (e.g. error), so default to empty object
const { cost_mUsd, is_byok } = computeOpenRouterCostFields(
Expand All @@ -671,7 +677,7 @@ export function processOpenRouterUsage(
0,
outputTokens: usage?.completion_tokens ?? 0,
cost_mUsd,
is_byok,
is_byok: is_byok ?? extractVercelIsByok(vercelProviderMetadata?.gateway),
};
}

Expand Down Expand Up @@ -703,6 +709,7 @@ export async function parseMicrodollarUsageFromStream(
let usage: OpenRouterUsage | null = null;
let inference_provider: string | null = null;
let finish_reason: string | null = null;
let vercelProviderMetadata: VercelProviderMetaData | null = null;

const sseStreamParser = createParser({
onEvent(event: EventSourceMessage) {
Expand Down Expand Up @@ -744,9 +751,13 @@ export async function parseMicrodollarUsageFromStream(
messageId = json.id ?? messageId;
usage = json.usage ?? usage;
const choice = json.choices?.[0];
const chunkProviderMetadata = choice?.delta?.provider_metadata;
if (chunkProviderMetadata) {
vercelProviderMetadata = chunkProviderMetadata;
}
inference_provider =
json.provider ??
choice?.delta?.provider_metadata?.gateway?.routing?.finalProvider ??
chunkProviderMetadata?.gateway?.routing?.finalProvider ??
inference_provider;
finish_reason = choice?.finish_reason ?? finish_reason;

Expand Down Expand Up @@ -788,7 +799,7 @@ export async function parseMicrodollarUsageFromStream(
status_code: effectiveStatusCode,
};

const costs = processOpenRouterUsage(usage, coreProps);
const costs = processOpenRouterUsage(usage, coreProps, vercelProviderMetadata);

return { ...coreProps, ...costs };
}
Expand Down Expand Up @@ -831,7 +842,11 @@ export function parseMicrodollarUsageFromString(
status_code: statusCode,
};

const costs = processOpenRouterUsage(responseJson?.usage, coreProps);
const costs = processOpenRouterUsage(
responseJson?.usage,
coreProps,
choice?.message?.provider_metadata ?? null
);

return { ...coreProps, ...costs };
}
Expand Down
20 changes: 19 additions & 1 deletion apps/web/src/lib/ai-gateway/processUsage.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,26 @@ export type OpenRouterUsage = {
total_tokens: number;
}; //ref: https://openrouter.ai/docs/use-cases/usage-accounting#response-format

export type VercelProviderAttempt = {
provider?: string;
credentialType?: string;
success?: boolean;
};

export type VercelModelAttempt = {
success?: boolean;
providerAttempts?: VercelProviderAttempt[];
};

export type VercelProviderMetaData = {
gateway?: { routing?: { finalProvider?: string }; cost?: string; marketCost?: string };
gateway?: {
routing?: {
finalProvider?: string;
modelAttempts?: VercelModelAttempt[];
};
cost?: string;
marketCost?: string;
};
};

export type MaybeHasVercelProviderMetaData = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
"cacheHitTokens": 0,
"cacheWriteTokens": 0,
"cost_mUsd": 402,
"is_byok": null
"is_byok": true
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
"cacheHitTokens": 0,
"cacheWriteTokens": 0,
"cost_mUsd": 402,
"is_byok": null
"is_byok": true
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
"cacheHitTokens": 0,
"cacheWriteTokens": 0,
"cost_mUsd": 6138,
"is_byok": null
"is_byok": true
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
"cacheHitTokens": 0,
"cacheWriteTokens": 0,
"cost_mUsd": 6138,
"is_byok": null
"is_byok": true
}