1
0
forked from boos/BoosAPI

feat: update error messages in NewApiAdapter tests to use correct Chinese translations

feat: enhance proxy usage parser to include cache read and creation tokens

feat: add billing details to ProxyLog interface and implement related formatting functions

fix: correct mojibake issues in source files and add regression test for mojibake detection

chore: add SQL migration for billing details in proxy_logs table

test: improve site speed button tests to ensure proper state updates without DOM lookups
This commit is contained in:
cita
2026-03-06 17:10:01 +08:00
parent 2a810facb8
commit 55b1ae564e
23 changed files with 1298 additions and 344 deletions
@@ -318,7 +318,7 @@ async function ensureSchema(client: SqlClient): Promise<void> {
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" BOOLEAN DEFAULT FALSE, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
@@ -333,7 +333,7 @@ async function ensureSchema(client: SqlClient): Promise<void> {
`CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`downstream_api_keys\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`key\` VARCHAR(191) NOT NULL UNIQUE, \`description\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`expires_at\` TEXT NULL, \`max_cost\` DOUBLE NULL, \`used_cost\` DOUBLE DEFAULT 0, \`max_requests\` INT NULL, \`used_requests\` INT DEFAULT 0, \`supported_models\` TEXT NULL, \`allowed_route_ids\` TEXT NULL, \`site_weight_multipliers\` TEXT NULL, \`last_used_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`events\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`type\` VARCHAR(32) NOT NULL, \`title\` TEXT NOT NULL, \`message\` TEXT NULL, \`level\` VARCHAR(16) DEFAULT 'info', \`read\` BOOLEAN DEFAULT FALSE, \`related_id\` INT NULL, \`related_type\` VARCHAR(32) NULL, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`,
@@ -347,7 +347,7 @@ async function ensureSchema(client: SqlClient): Promise<void> {
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" INTEGER DEFAULT 1, "expires_at" TEXT, "max_cost" REAL, "used_cost" REAL DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" INTEGER DEFAULT 0, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
@@ -548,7 +548,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
for (const row of snapshot.accounts.proxyLogs) {
statements.push({
table: 'proxy_logs',
columns: ['id', 'route_id', 'channel_id', 'account_id', 'model_requested', 'model_actual', 'status', 'http_status', 'latency_ms', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'estimated_cost', 'error_message', 'retry_count', 'created_at'],
columns: ['id', 'route_id', 'channel_id', 'account_id', 'model_requested', 'model_actual', 'status', 'http_status', 'latency_ms', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'estimated_cost', 'billing_details', 'error_message', 'retry_count', 'created_at'],
values: [
asNumber(row.id, 0),
asNumber(row.routeId, null),
@@ -563,6 +563,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
asNumber(row.completionTokens, null),
asNumber(row.totalTokens, null),
asNumber(row.estimatedCost, null),
asNullableString(row.billingDetails),
asNullableString(row.errorMessage),
asNumber(row.retryCount, 0),
asNullableString(row.createdAt),
@@ -729,4 +730,3 @@ export async function testDatabaseConnection(input: DatabaseMigrationInput): Pro
connection: maskConnectionString(normalized.connectionString),
};
}
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { calculateModelUsageCost, fallbackTokenCost, type PricingModel } from './modelPricingService.js';
import {
calculateModelUsageBreakdown,
calculateModelUsageCost,
fallbackTokenCost,
type PricingModel,
} from './modelPricingService.js';
describe('modelPricingService', () => {
it('calculates token-based cost from model ratio and completion ratio', () => {
@@ -94,6 +99,86 @@ describe('modelPricingService', () => {
expect(cost).toBe(0.004);
});
it('splits cache read and cache creation costs from prompt cost', () => {
const model: PricingModel = {
modelName: 'gpt-4o',
quotaType: 0,
modelRatio: 2.5,
completionRatio: 5,
cacheRatio: 0.1,
cacheCreationRatio: 1.25,
modelPrice: null,
enableGroups: ['default'],
};
const detail = calculateModelUsageBreakdown(
model,
{
promptTokens: 146638,
completionTokens: 172,
totalTokens: 146810,
cacheReadTokens: 145692,
cacheCreationTokens: 945,
promptTokensIncludeCache: true,
},
{ default: 1 },
);
expect(detail).toMatchObject({
usage: {
billablePromptTokens: 1,
cacheReadTokens: 145692,
cacheCreationTokens: 945,
},
pricing: {
modelRatio: 2.5,
completionRatio: 5,
cacheRatio: 0.1,
cacheCreationRatio: 1.25,
groupRatio: 1,
},
breakdown: {
inputPerMillion: 5,
outputPerMillion: 25,
cacheReadPerMillion: 0.5,
cacheCreationPerMillion: 6.25,
inputCost: 0.000005,
outputCost: 0.0043,
cacheReadCost: 0.072846,
cacheCreationCost: 0.005906,
totalCost: 0.083057,
},
});
});
it('keeps prompt tokens intact when upstream reports cache tokens separately', () => {
const model: PricingModel = {
modelName: 'claude-sonnet',
quotaType: 0,
modelRatio: 3,
completionRatio: 5,
cacheRatio: 0.3,
cacheCreationRatio: 1.25,
modelPrice: null,
enableGroups: ['default'],
};
const cost = calculateModelUsageCost(
model,
{
promptTokens: 120,
completionTokens: 30,
totalTokens: 150,
cacheReadTokens: 1000,
cacheCreationTokens: 40,
promptTokensIncludeCache: false,
},
{ default: 1 },
);
expect(cost).toBe(0.00372);
});
it('uses platform-specific fallback token divisor', () => {
expect(fallbackTokenCost(1500, 'new-api')).toBe(0.003);
expect(fallbackTokenCost(1500, 'veloera')).toBe(0.0015);
+197 -15
View File
@@ -18,6 +18,8 @@ export interface PricingModel {
quotaType: number;
modelRatio: number;
completionRatio: number;
cacheRatio?: number;
cacheCreationRatio?: number;
modelPrice: number | { input: number; output: number } | null;
enableGroups: string[];
modelDescription?: string | null;
@@ -59,12 +61,17 @@ interface EstimateProxyCostInput {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
cacheReadTokens?: number;
cacheCreationTokens?: number;
promptTokensIncludeCache?: boolean | null;
}
interface ModelGroupPricing {
quotaType: number;
inputPerMillion?: number;
outputPerMillion?: number;
cacheReadPerMillion?: number;
cacheCreationPerMillion?: number;
perCallInput?: number;
perCallOutput?: number;
perCallTotal?: number;
@@ -86,6 +93,37 @@ interface ModelPricingCatalog {
groupRatio: Record<string, number>;
}
export interface ProxyBillingDetails {
quotaType: number;
usage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
billablePromptTokens: number;
promptTokensIncludeCache: boolean | null;
};
pricing: {
modelRatio: number;
completionRatio: number;
cacheRatio: number;
cacheCreationRatio: number;
groupRatio: number;
};
breakdown: {
inputPerMillion: number;
outputPerMillion: number;
cacheReadPerMillion: number;
cacheCreationPerMillion: number;
inputCost: number;
outputCost: number;
cacheReadCost: number;
cacheCreationCost: number;
totalCost: number;
};
}
const pricingCache = new Map<string, PricingCacheEntry>();
const routingReferenceCostCache = new Map<string, RoutingReferenceCostCacheEntry>();
@@ -150,6 +188,12 @@ function normalizeStringArray(raw: unknown): string[] {
return [];
}
function normalizeRatio(value: unknown, fallback: number): number {
const ratio = toNumber(value, Number.NaN);
if (Number.isFinite(ratio) && ratio >= 0) return ratio;
return fallback;
}
function normalizePricingModels(rawModels: unknown[]): Map<string, PricingModel> {
const models = new Map<string, PricingModel>();
@@ -162,6 +206,17 @@ function normalizePricingModels(rawModels: unknown[]): Map<string, PricingModel>
const quotaType = toPositiveInt((raw as any).quota_type);
const modelRatio = toNumber((raw as any).model_ratio, 1);
const completionRatio = toNumber((raw as any).completion_ratio, 1);
const cacheRatio = normalizeRatio(
(raw as any).cache_ratio ?? (raw as any).cacheRatio,
1,
);
const cacheCreationRatio = normalizeRatio(
(raw as any).cache_creation_ratio
?? (raw as any).cacheCreationRatio
?? (raw as any).create_cache_ratio
?? (raw as any).createCacheRatio,
1,
);
const enableGroupsRaw = (raw as any).enable_groups;
const enableGroups = Array.isArray(enableGroupsRaw)
? enableGroupsRaw.map((item: unknown) => String(item || '').trim()).filter(Boolean)
@@ -180,6 +235,8 @@ function normalizePricingModels(rawModels: unknown[]): Map<string, PricingModel>
quotaType,
modelRatio: modelRatio > 0 ? modelRatio : 1,
completionRatio: completionRatio > 0 ? completionRatio : 1,
cacheRatio,
cacheCreationRatio,
modelPrice: normalizeModelPrice((raw as any).model_price),
enableGroups: enableGroups.length > 0 ? enableGroups : [DEFAULT_GROUP],
modelDescription,
@@ -219,6 +276,14 @@ function normalizeOneHubPricingPayload(availablePayload: unknown, groupPayload:
const price = item?.price || {};
const input = toNumber(price.input, 0);
const output = toNumber(price.output, input);
const cacheRead = toNumber(
price.input_cache_read ?? price.inputCacheRead ?? price.cache_read ?? price.cacheRead,
Number.NaN,
);
const cacheWrite = toNumber(
price.input_cache_write ?? price.inputCacheWrite ?? price.cache_write ?? price.cacheWrite,
Number.NaN,
);
const isTokenType = String(price.type || '').toLowerCase() === 'tokens';
transformed.push({
@@ -227,6 +292,8 @@ function normalizeOneHubPricingPayload(availablePayload: unknown, groupPayload:
quota_type: isTokenType ? 0 : 1,
model_ratio: 1,
completion_ratio: input > 0 ? output / input : 1,
cache_ratio: input > 0 && Number.isFinite(cacheRead) && cacheRead >= 0 ? (cacheRead / input) : 1,
cache_creation_ratio: input > 0 && Number.isFinite(cacheWrite) && cacheWrite >= 0 ? (cacheWrite / input) : 1,
model_price: { input, output },
enable_groups: Array.isArray(item?.groups) && item.groups.length > 0 ? item.groups : [DEFAULT_GROUP],
supported_endpoint_types: Array.isArray(item?.supported_endpoint_types) ? item.supported_endpoint_types : [],
@@ -494,9 +561,102 @@ function calculatePerCallPricing(
return { total: 0 };
}
function normalizeUsageBreakdownInput(usage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
cacheReadTokens?: number;
cacheCreationTokens?: number;
promptTokensIncludeCache?: boolean | null;
}) {
const promptTokens = toPositiveInt(usage.promptTokens);
const completionTokens = toPositiveInt(usage.completionTokens);
const totalTokensRaw = toPositiveInt(usage.totalTokens);
const totalTokens = Math.max(totalTokensRaw, promptTokens + completionTokens);
const cacheReadTokens = toPositiveInt(usage.cacheReadTokens);
const cacheCreationTokens = toPositiveInt(usage.cacheCreationTokens);
const promptTokensIncludeCache = usage.promptTokensIncludeCache ?? null;
const hasSplit = promptTokens > 0 || completionTokens > 0;
const effectivePromptTokens = hasSplit ? promptTokens : totalTokens;
const billablePromptTokens = promptTokensIncludeCache === false
? effectivePromptTokens
: Math.max(0, effectivePromptTokens - cacheReadTokens - cacheCreationTokens);
return {
promptTokens,
completionTokens,
totalTokens,
cacheReadTokens,
cacheCreationTokens,
billablePromptTokens,
promptTokensIncludeCache,
};
}
export function calculateModelUsageBreakdown(
model: PricingModel,
usage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
cacheReadTokens?: number;
cacheCreationTokens?: number;
promptTokensIncludeCache?: boolean | null;
},
groupRatio: Record<string, number>,
): ProxyBillingDetails | null {
if (model.quotaType === 1) {
return null;
}
const multiplier = resolveGroupMultiplier(model, groupRatio);
const normalizedUsage = normalizeUsageBreakdownInput(usage);
const cacheRatio = model.cacheRatio ?? 1;
const cacheCreationRatio = model.cacheCreationRatio ?? 1;
const inputPerMillion = roundCost(model.modelRatio * 2 * multiplier);
const outputPerMillion = roundCost(model.modelRatio * model.completionRatio * 2 * multiplier);
const cacheReadPerMillion = roundCost(model.modelRatio * cacheRatio * 2 * multiplier);
const cacheCreationPerMillion = roundCost(model.modelRatio * cacheCreationRatio * 2 * multiplier);
const inputCost = roundCost((normalizedUsage.billablePromptTokens / 1_000_000) * inputPerMillion);
const outputCost = roundCost((normalizedUsage.completionTokens / 1_000_000) * outputPerMillion);
const cacheReadCost = roundCost((normalizedUsage.cacheReadTokens / 1_000_000) * cacheReadPerMillion);
const cacheCreationCost = roundCost((normalizedUsage.cacheCreationTokens / 1_000_000) * cacheCreationPerMillion);
const totalCost = roundCost(inputCost + outputCost + cacheReadCost + cacheCreationCost);
return {
quotaType: model.quotaType,
usage: normalizedUsage,
pricing: {
modelRatio: model.modelRatio,
completionRatio: model.completionRatio,
cacheRatio,
cacheCreationRatio,
groupRatio: multiplier,
},
breakdown: {
inputPerMillion,
outputPerMillion,
cacheReadPerMillion,
cacheCreationPerMillion,
inputCost,
outputCost,
cacheReadCost,
cacheCreationCost,
totalCost,
},
};
}
export function calculateModelUsageCost(
model: PricingModel,
usage: { promptTokens: number; completionTokens: number; totalTokens: number },
usage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
cacheReadTokens?: number;
cacheCreationTokens?: number;
promptTokensIncludeCache?: boolean | null;
},
groupRatio: Record<string, number>,
): number {
const multiplier = resolveGroupMultiplier(model, groupRatio);
@@ -505,19 +665,7 @@ export function calculateModelUsageCost(
return roundCost(calculatePerCallCost(model.modelPrice, multiplier));
}
const totalTokens = toPositiveInt(usage.totalTokens);
const promptTokens = toPositiveInt(usage.promptTokens);
const completionTokens = toPositiveInt(usage.completionTokens);
const hasSplit = promptTokens > 0 || completionTokens > 0;
const effectivePrompt = hasSplit ? promptTokens : totalTokens;
const effectiveCompletion = hasSplit ? completionTokens : 0;
const inputPerMillion = model.modelRatio * 2 * multiplier;
const outputPerMillion = model.modelRatio * model.completionRatio * 2 * multiplier;
const cost = (effectivePrompt / 1_000_000) * inputPerMillion
+ (effectiveCompletion / 1_000_000) * outputPerMillion;
return roundCost(cost);
return calculateModelUsageBreakdown(model, usage, groupRatio)?.breakdown.totalCost ?? 0;
}
export async function fetchModelPricingCatalog(input: EstimateProxyCostInput): Promise<ModelPricingCatalog | null> {
@@ -550,6 +698,8 @@ export async function fetchModelPricingCatalog(input: EstimateProxyCostInput): P
quotaType: 0,
inputPerMillion: roundCost(model.modelRatio * 2 * multiplier),
outputPerMillion: roundCost(model.modelRatio * model.completionRatio * 2 * multiplier),
cacheReadPerMillion: roundCost(model.modelRatio * (model.cacheRatio ?? 1) * 2 * multiplier),
cacheCreationPerMillion: roundCost(model.modelRatio * (model.cacheCreationRatio ?? 1) * 2 * multiplier),
};
return acc;
}, {});
@@ -594,8 +744,40 @@ export async function estimateProxyCost(input: EstimateProxyCostInput): Promise<
return fallbackTokenCost(totalTokens, input.site.platform);
}
return calculateModelUsageCost(model, { promptTokens, completionTokens, totalTokens }, pricingData.groupRatio);
return calculateModelUsageCost(model, {
promptTokens,
completionTokens,
totalTokens,
cacheReadTokens: input.cacheReadTokens,
cacheCreationTokens: input.cacheCreationTokens,
promptTokensIncludeCache: input.promptTokensIncludeCache,
}, pricingData.groupRatio);
} catch {
return fallbackTokenCost(totalTokens, input.site.platform);
}
}
export async function buildProxyBillingDetails(input: EstimateProxyCostInput): Promise<ProxyBillingDetails | null> {
const promptTokens = toPositiveInt(input.promptTokens);
const completionTokens = toPositiveInt(input.completionTokens);
const totalTokens = toPositiveInt(input.totalTokens || (promptTokens + completionTokens));
try {
const pricingData = await getPricingDataCached(input);
if (!pricingData) return null;
const model = resolveModel(input.modelName, pricingData);
if (!model || model.quotaType === 1) return null;
return calculateModelUsageBreakdown(model, {
promptTokens,
completionTokens,
totalTokens,
cacheReadTokens: input.cacheReadTokens,
cacheCreationTokens: input.cacheCreationTokens,
promptTokensIncludeCache: input.promptTokensIncludeCache,
}, pricingData.groupRatio);
} catch {
return null;
}
}
+6 -6
View File
@@ -210,7 +210,7 @@ describe('NewApiAdapter', () => {
if (req.url === '/api/user/self') {
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${BALANCE_FAIL_TOKEN}`) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: '閺冪姵娼堟潻娑滎攽濮濄倖鎼锋担婊愮礉閺堫亞娅ヨぐ鏇氱瑬閺堫亝褰佹笟?access token' }));
res.end(JSON.stringify({ success: false, message: '无权进行此操作,access token 无效' }));
return;
}
@@ -222,7 +222,7 @@ describe('NewApiAdapter', () => {
)
) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: '閺冪姵娼堟潻娑滎攽濮濄倖鎼锋担婊愮礉閺堫亞娅ヨぐ鏇氱瑬閺堫亝褰佹笟?access token' }));
res.end(JSON.stringify({ success: false, message: '无权进行此操作,access token 无效' }));
return;
}
@@ -316,12 +316,12 @@ describe('NewApiAdapter', () => {
}
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${CHECKIN_ALREADY_TOKEN}`) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: '浠婂ぉ宸茬粡绛惧埌杩囧暒' }));
res.end(JSON.stringify({ success: false, message: '今天已经签到过啦' }));
return;
}
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${CHECKIN_ALREADY_TOKEN}`)) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: '鏃犳潈杩涜姝ゆ搷浣滐紝鏈櫥褰曚笖鏈彁渚?access token' }));
res.end(JSON.stringify({ success: false, message: '无权进行此操作,未登录且未提供 access token' }));
return;
}
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_SHIELDED_TOKEN}`) {
@@ -349,7 +349,7 @@ describe('NewApiAdapter', () => {
if (req.url === '/api/user/sign_in') {
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${CHECKIN_ALREADY_TOKEN}`)) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: '鏃犳潈杩涜姝ゆ搷浣滐紝鏈櫥褰曚笖鏈彁渚?access token' }));
res.end(JSON.stringify({ success: false, message: '无权进行此操作,未登录且未提供 access token' }));
return;
}
}
@@ -510,7 +510,7 @@ describe('NewApiAdapter', () => {
const result = await adapter.checkin(baseUrl, CHECKIN_ALREADY_TOKEN, 11494);
expect(result.success).toBe(false);
expect(result.message).toBe('浠婂ぉ宸茬粡绛惧埌杩囧暒');
expect(result.message).toBe('今天已经签到过啦');
});
it('sends all compatibility user-id headers when userId is known', async () => {
+54 -2
View File
@@ -19,6 +19,9 @@ describe('proxyUsageParser', () => {
promptTokens: 123,
completionTokens: 45,
totalTokens: 168,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
});
});
@@ -34,6 +37,9 @@ describe('proxyUsageParser', () => {
promptTokens: 80,
completionTokens: 20,
totalTokens: 100,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
});
});
@@ -50,6 +56,9 @@ describe('proxyUsageParser', () => {
promptTokens: 12,
completionTokens: 34,
totalTokens: 46,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
});
});
@@ -72,6 +81,9 @@ describe('proxyUsageParser', () => {
promptTokens: 210,
completionTokens: 40,
totalTokens: 250,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
});
});
@@ -92,19 +104,59 @@ describe('proxyUsageParser', () => {
promptTokens: 10,
completionTokens: 20,
totalTokens: 30,
cacheReadTokens: 3,
cacheCreationTokens: 0,
promptTokensIncludeCache: true,
});
});
it('parses anthropic cache usage fields without treating input tokens as cache-inclusive', () => {
const usage = parseProxyUsage({
usage: {
input_tokens: 120,
output_tokens: 30,
cache_read_input_tokens: 1000,
cache_creation_input_tokens: 40,
},
});
expect(usage).toEqual({
promptTokens: 120,
completionTokens: 30,
totalTokens: 150,
cacheReadTokens: 1000,
cacheCreationTokens: 40,
promptTokensIncludeCache: false,
});
});
it('merges usage snapshots by keeping richer values', () => {
const merged = mergeProxyUsage(
{ promptTokens: 0, completionTokens: 0, totalTokens: 0 },
{ promptTokens: 90, completionTokens: 30, totalTokens: 120 },
{
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
},
{
promptTokens: 90,
completionTokens: 30,
totalTokens: 120,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
},
);
expect(merged).toEqual({
promptTokens: 90,
completionTokens: 30,
totalTokens: 120,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
});
});
+122 -5
View File
@@ -2,12 +2,18 @@ interface ParsedProxyUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
promptTokensIncludeCache: boolean | null;
}
const ZERO_USAGE: ParsedProxyUsage = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
};
function toPositiveInt(value: unknown): number {
@@ -76,6 +82,84 @@ function sumNumericFields(value: unknown): number {
return Object.values(value).reduce<number>((sum, item) => sum + toPositiveInt(item), 0);
}
function detectPromptTokensIncludeCache(record: Record<string, unknown>): boolean | null {
const hasAnthropicCacheFields = [
'cache_read_input_tokens',
'cacheReadInputTokens',
'cache_creation_input_tokens',
'cacheCreationInputTokens',
'cache_creation',
'cacheCreation',
'claude_cache_creation_5_m_tokens',
'claudeCacheCreation5mTokens',
'claude_cache_creation_1_h_tokens',
'claudeCacheCreation1hTokens',
].some((key) => key in record);
if (hasAnthropicCacheFields) return false;
const hasDetailCacheFields = [
'prompt_tokens_details',
'promptTokensDetails',
'input_tokens_details',
'inputTokensDetails',
'prompt_cache_hit_tokens',
'promptCacheHitTokens',
].some((key) => key in record);
if (hasDetailCacheFields) return true;
return null;
}
function getCacheReadTokens(record: Record<string, unknown>): number {
const direct = firstPositiveInt(record, [
'cache_read_input_tokens',
'cacheReadInputTokens',
'prompt_cache_hit_tokens',
'promptCacheHitTokens',
'cached_tokens',
'cachedTokens',
'cache_read_tokens',
'cacheReadTokens',
]);
if (direct > 0) return direct;
return Math.max(
toPositiveInt((record.prompt_tokens_details as any)?.cached_tokens),
toPositiveInt((record.promptTokensDetails as any)?.cachedTokens),
toPositiveInt((record.input_tokens_details as any)?.cached_tokens),
toPositiveInt((record.inputTokensDetails as any)?.cachedTokens),
);
}
function getCacheCreationTokens(record: Record<string, unknown>): number {
const direct = firstPositiveInt(record, [
'cache_creation_input_tokens',
'cacheCreationInputTokens',
'cache_creation_tokens',
'cacheCreationTokens',
]);
if (direct > 0) return direct;
const split = Math.max(
toPositiveInt((record.cache_creation as any)?.ephemeral_5m_input_tokens)
+ toPositiveInt((record.cache_creation as any)?.ephemeral_1h_input_tokens),
toPositiveInt((record.cacheCreation as any)?.ephemeral5mInputTokens)
+ toPositiveInt((record.cacheCreation as any)?.ephemeral1hInputTokens),
toPositiveInt(record.claude_cache_creation_5_m_tokens)
+ toPositiveInt(record.claude_cache_creation_1_h_tokens),
toPositiveInt(record.claudeCacheCreation5mTokens)
+ toPositiveInt(record.claudeCacheCreation1hTokens),
);
if (split > 0) return split;
return Math.max(
toPositiveInt((record.prompt_tokens_details as any)?.cache_creation_tokens),
toPositiveInt((record.promptTokensDetails as any)?.cacheCreationTokens),
toPositiveInt((record.input_tokens_details as any)?.cache_creation_tokens),
toPositiveInt((record.inputTokensDetails as any)?.cacheCreationTokens),
);
}
function parseUsageRecord(record: Record<string, unknown>): ParsedProxyUsage {
let promptTokens = firstPositiveInt(record, [
'prompt_tokens',
@@ -105,6 +189,9 @@ function parseUsageRecord(record: Record<string, unknown>): ParsedProxyUsage {
'total_token_count',
'totalTokenCount',
]);
const cacheReadTokens = getCacheReadTokens(record);
const cacheCreationTokens = getCacheCreationTokens(record);
const promptTokensIncludeCache = detectPromptTokensIncludeCache(record);
if (promptTokens <= 0) {
promptTokens = Math.max(
@@ -139,6 +226,9 @@ function parseUsageRecord(record: Record<string, unknown>): ParsedProxyUsage {
promptTokens,
completionTokens,
totalTokens: Math.max(totalTokens, promptTokens + completionTokens),
cacheReadTokens,
cacheCreationTokens,
promptTokensIncludeCache,
};
}
@@ -164,18 +254,45 @@ export function parseProxyUsage(payload: unknown): ParsedProxyUsage {
}
export function mergeProxyUsage(base: ParsedProxyUsage, incoming: ParsedProxyUsage): ParsedProxyUsage {
const baseScore = base.totalTokens > 0 ? base.totalTokens * 10_000 + base.promptTokens + base.completionTokens : (base.promptTokens + base.completionTokens);
const normalizeUsage = (usage: ParsedProxyUsage): ParsedProxyUsage => ({
promptTokens: toPositiveInt(usage.promptTokens),
completionTokens: toPositiveInt(usage.completionTokens),
totalTokens: Math.max(
toPositiveInt(usage.totalTokens),
toPositiveInt(usage.promptTokens) + toPositiveInt(usage.completionTokens),
),
cacheReadTokens: toPositiveInt(usage.cacheReadTokens),
cacheCreationTokens: toPositiveInt(usage.cacheCreationTokens),
promptTokensIncludeCache: usage.promptTokensIncludeCache ?? null,
});
const baseCacheReadTokens = toPositiveInt(base.cacheReadTokens);
const baseCacheCreationTokens = toPositiveInt(base.cacheCreationTokens);
const incomingCacheReadTokens = toPositiveInt(incoming.cacheReadTokens);
const incomingCacheCreationTokens = toPositiveInt(incoming.cacheCreationTokens);
const baseScore = base.totalTokens > 0
? (base.totalTokens * 10_000 + base.promptTokens + base.completionTokens + baseCacheReadTokens + baseCacheCreationTokens)
: (base.promptTokens + base.completionTokens + baseCacheReadTokens + baseCacheCreationTokens);
const incomingScore = incoming.totalTokens > 0
? incoming.totalTokens * 10_000 + incoming.promptTokens + incoming.completionTokens
: (incoming.promptTokens + incoming.completionTokens);
? (incoming.totalTokens * 10_000 + incoming.promptTokens + incoming.completionTokens + incomingCacheReadTokens + incomingCacheCreationTokens)
: (incoming.promptTokens + incoming.completionTokens + incomingCacheReadTokens + incomingCacheCreationTokens);
if (incomingScore > baseScore) return incoming;
if (incomingScore > baseScore) return normalizeUsage(incoming);
const promptTokens = Math.max(base.promptTokens, incoming.promptTokens);
const completionTokens = Math.max(base.completionTokens, incoming.completionTokens);
const totalTokens = Math.max(base.totalTokens, incoming.totalTokens, promptTokens + completionTokens);
const cacheReadTokens = Math.max(baseCacheReadTokens, incomingCacheReadTokens);
const cacheCreationTokens = Math.max(baseCacheCreationTokens, incomingCacheCreationTokens);
const promptTokensIncludeCache = incoming.promptTokensIncludeCache ?? base.promptTokensIncludeCache;
return { promptTokens, completionTokens, totalTokens };
return normalizeUsage({
promptTokens,
completionTokens,
totalTokens,
cacheReadTokens,
cacheCreationTokens,
promptTokensIncludeCache,
});
}
export function pullSseDataEvents(buffer: string): { events: string[]; rest: string } {