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
+99
View File
@@ -0,0 +1,99 @@
# Source Encoding Cleanup Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Remove mojibake-corrupted Chinese literals from source and tests, then add regression coverage so the corruption does not re-enter the repository unnoticed.
**Architecture:** Keep the fix narrow: add one regression test that scans `src/` for known mojibake markers, then replace each corrupted literal with the intended UTF-8 text in the affected source and test files. Verification stays focused on the new guard plus the currently failing Vitest files.
**Tech Stack:** TypeScript, Vitest, Node.js filesystem APIs
---
### Task 1: Add a failing mojibake regression test
**Files:**
- Create: `src/encoding.mojibake.test.ts`
- Test: `src/encoding.mojibake.test.ts`
**Step 1: Write the failing test**
Create a Vitest file that scans `src/**/*.{ts,tsx,js,jsx}` and fails when any source line contains known mojibake markers currently present in the repo.
**Step 2: Run test to verify it fails**
Run: `npm test -- src/encoding.mojibake.test.ts`
Expected: FAIL with matches from the currently corrupted files.
**Step 3: Keep the failure output readable**
List file paths, line numbers, and the matched marker text in the assertion message.
**Step 4: Re-run test to verify it still fails for the expected reason**
Run: `npm test -- src/encoding.mojibake.test.ts`
Expected: FAIL and only point at real mojibake matches.
### Task 2: Repair corrupted source literals
**Files:**
- Modify: `src/server/routes/api/accounts.ts`
- Modify: `src/web/i18n.supplement.ts`
**Step 1: Replace the unsupported-platform mojibake message**
Use a single readable Chinese message in `accounts.ts`, aligned with the existing translation key in `i18n.supplement.ts`.
**Step 2: Remove or normalize the matching corrupted i18n entry**
Keep only the valid UTF-8 translation key/value pair for that backend message.
**Step 3: Run the mojibake test**
Run: `npm test -- src/encoding.mojibake.test.ts`
Expected: still FAIL, but no longer report the repaired source files.
### Task 3: Repair corrupted test fixtures and selectors
**Files:**
- Modify: `src/web/pages/dashboard.site-speed-button.test.tsx`
- Modify: `src/server/services/platforms/newApi.test.ts`
**Step 1: Fix the dashboard selector text**
Replace the corrupted speed-button label match with the intended UTF-8 text.
**Step 2: Fix corrupted upstream fixture/expectation messages in `newApi.test.ts`**
Use readable Chinese strings that match the adapter’s intended behavior.
**Step 3: Run focused tests**
Run: `npm test -- src/web/pages/dashboard.site-speed-button.test.tsx`
Expected: PASS
Run: `npm test -- src/server/services/platforms/newApi.test.ts`
Expected: PASS
### Task 4: Verify end-to-end cleanup
**Files:**
- Test: `src/encoding.mojibake.test.ts`
- Test: `src/web/pages/accounts.rebind-panel-focus.test.tsx`
- Test: `src/web/pages/dashboard.site-speed-button.test.tsx`
- Test: `src/server/services/platforms/newApi.test.ts`
**Step 1: Re-run the regression guard**
Run: `npm test -- src/encoding.mojibake.test.ts`
Expected: PASS
**Step 2: Re-run the CI-facing tests**
Run: `npm test -- src/web/pages/accounts.rebind-panel-focus.test.tsx src/web/pages/dashboard.site-speed-button.test.tsx`
Expected: PASS
**Step 3: Re-run the backend platform test**
Run: `npm test -- src/server/services/platforms/newApi.test.ts`
Expected: PASS
@@ -0,0 +1 @@
ALTER TABLE `proxy_logs` ADD `billing_details` text;
+55
View File
@@ -0,0 +1,55 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { describe, expect, it } from 'vitest';
const SOURCE_ROOT = join(process.cwd(), 'src');
const TEST_FILE = join(SOURCE_ROOT, 'encoding.mojibake.test.ts');
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']);
const MOJIBAKE_MARKERS = [
'娴嬮',
'婵炴垶',
'浠婂ぉ宸茬粡',
'鏃犳潈杩涜',
'閺冪姵娼堟潻',
];
function collectSourceFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir)) {
const fullPath = join(dir, entry);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
files.push(...collectSourceFiles(fullPath));
continue;
}
const extension = fullPath.slice(fullPath.lastIndexOf('.'));
if (SOURCE_EXTENSIONS.has(extension)) {
files.push(fullPath);
}
}
return files;
}
describe('source text integrity', () => {
it('does not contain known mojibake markers', () => {
const hits: string[] = [];
for (const file of collectSourceFiles(SOURCE_ROOT)) {
if (file === TEST_FILE) continue;
const lines = readFileSync(file, 'utf8').split(/\r?\n/u);
lines.forEach((line, index) => {
for (const marker of MOJIBAKE_MARKERS) {
if (!line.includes(marker)) continue;
hits.push(`${relative(process.cwd(), file)}:${index + 1} contains "${marker}"`);
}
});
}
expect(hits).toEqual([]);
});
});
+12
View File
@@ -263,6 +263,17 @@ function ensureDownstreamApiKeySchema() {
`);
}
function ensureProxyLogBillingDetailsSchema() {
const sqlite = requireSqliteConnection();
if (!tableExists('proxy_logs')) {
return;
}
if (!tableColumnExists('proxy_logs', 'billing_details')) {
sqlite.exec('ALTER TABLE proxy_logs ADD COLUMN billing_details text;');
}
}
async function sqliteProxyQuery(sqlText: string, params: unknown[], method: SqlMethod) {
const sqlite = requireSqliteConnection();
const statement = sqlite.prepare(sqlText);
@@ -518,6 +529,7 @@ function initSqliteDb() {
ensureSiteGlobalWeightSchema();
ensureRouteGroupingSchema();
ensureDownstreamApiKeySchema();
ensureProxyLogBillingDetailsSchema();
const rawDb = drizzleSqliteProxy(
(sqlText, params, method) => sqliteProxyQuery(sqlText, params, method as SqlMethod),
+1
View File
@@ -155,6 +155,7 @@ export const proxyLogs = sqliteTable('proxy_logs', {
completionTokens: integer('completion_tokens'),
totalTokens: integer('total_tokens'),
estimatedCost: real('estimated_cost'),
billingDetails: text('billing_details'),
errorMessage: text('error_message'),
retryCount: integer('retry_count').default(0),
createdAt: text('created_at').default(sql`(datetime('now'))`),
+11 -9
View File
@@ -409,7 +409,7 @@ export async function accountsRoutes(app: FastifyInstance) {
// Get platform adapter
const adapter = getAdapter(site.platform);
if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
if (!adapter) return { success: false, message: `不支持的平台: ${site.platform}` };
// Login to the target site
const loginResult = await adapter.login(site.url, username, password);
@@ -523,10 +523,13 @@ export async function accountsRoutes(app: FastifyInstance) {
}
const adapter = getAdapter(site.platform);
if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
if (!adapter) return { success: false, message: `不支持的平台: ${site.platform}` };
const normalizedPlatform = String(adapter.platformName || site.platform || '').trim().toLowerCase();
const hasProvidedUserId = Number.isFinite(platformUserId) && platformUserId > 0;
const parsedPlatformUserId = typeof platformUserId === 'number' && Number.isFinite(platformUserId) && platformUserId > 0
? Math.trunc(platformUserId)
: undefined;
const hasProvidedUserId = parsedPlatformUserId !== undefined;
const skipRawShieldDetection = normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter';
const diagnoseVerificationFailure = async (): Promise<VerifyFailureReason> => {
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
@@ -563,7 +566,7 @@ export async function accountsRoutes(app: FastifyInstance) {
candidates.add(`token=${raw}`);
}
const diagnosticUserId = hasProvidedUserId ? String(platformUserId) : '0';
const diagnosticUserId = hasProvidedUserId ? String(parsedPlatformUserId) : '0';
const headerVariants: Record<string, string>[] = [
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': diagnosticUserId },
];
@@ -624,7 +627,7 @@ export async function accountsRoutes(app: FastifyInstance) {
return null;
};
if (!platformUserId && (normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter')) {
if (!hasProvidedUserId && (normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter')) {
const preflightReason = await diagnoseVerificationFailure();
if (preflightReason === 'needs-user-id') {
return buildVerificationFailureResponse(preflightReason);
@@ -634,7 +637,7 @@ export async function accountsRoutes(app: FastifyInstance) {
if (credentialMode === 'apikey') {
try {
const models = await withTimeout(
() => adapter.getModels(site.url, accessToken, platformUserId),
() => adapter.getModels(site.url, accessToken, parsedPlatformUserId),
ACCOUNT_VERIFY_TIMEOUT_MS,
`Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`,
);
@@ -666,7 +669,7 @@ export async function accountsRoutes(app: FastifyInstance) {
let result: any;
try {
result = await withTimeout(
() => adapter.verifyToken(site.url, accessToken, platformUserId),
() => adapter.verifyToken(site.url, accessToken, parsedPlatformUserId),
ACCOUNT_VERIFY_TIMEOUT_MS,
`Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`,
);
@@ -742,7 +745,7 @@ export async function accountsRoutes(app: FastifyInstance) {
candidates.add(`token=${raw}`);
}
const diagnosticUserId = hasProvidedUserId ? String(platformUserId) : '0';
const diagnosticUserId = hasProvidedUserId ? String(parsedPlatformUserId) : '0';
const headerVariants: Record<string, string>[] = [
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': diagnosticUserId },
];
@@ -1251,4 +1254,3 @@ export async function accountsRoutes(app: FastifyInstance) {
});
}
+11
View File
@@ -25,6 +25,16 @@ function parseBooleanFlag(raw?: string): boolean {
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
function parseProxyLogBillingDetails(value: unknown): Record<string, unknown> | null {
if (typeof value !== 'string' || value.trim().length === 0) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null;
} catch {
return null;
}
}
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
@@ -187,6 +197,7 @@ export async function statsRoutes(app: FastifyInstance) {
return rows.map((row) => ({
...row.proxy_logs,
billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails),
username: row.accounts?.username || null,
siteName: row.sites?.name || null,
siteUrl: row.sites?.url || null,
@@ -10,6 +10,7 @@ const refreshModelsAndRebuildRoutesMock = vi.fn();
const reportProxyAllFailedMock = vi.fn();
const reportTokenExpiredMock = vi.fn();
const estimateProxyCostMock = vi.fn(async (_arg?: any) => 0);
const buildProxyBillingDetailsMock = vi.fn(async (_arg?: any) => null);
const fetchModelPricingCatalogMock = vi.fn(async (_arg?: any): Promise<any> => null);
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({
...usage,
@@ -50,6 +51,7 @@ vi.mock('../../services/alertRules.js', () => ({
vi.mock('../../services/modelPricingService.js', () => ({
estimateProxyCost: (arg: any) => estimateProxyCostMock(arg),
buildProxyBillingDetails: (arg: any) => buildProxyBillingDetailsMock(arg),
fetchModelPricingCatalog: (arg: any) => fetchModelPricingCatalogMock(arg),
}));
@@ -92,6 +94,7 @@ describe('chat proxy stream behavior', () => {
reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset();
estimateProxyCostMock.mockClear();
buildProxyBillingDetailsMock.mockClear();
fetchModelPricingCatalogMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
dbInsertMock.mockClear();
+59 -2
View File
@@ -5,7 +5,7 @@ import { fetch } from 'undici';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
import { isTokenExpiredError } from '../../services/alertRules.js';
import { estimateProxyCost } from '../../services/modelPricingService.js';
import { buildProxyBillingDetails, estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
@@ -347,7 +347,14 @@ async function handleChatProxyRequest(
const streamContext = createStreamTransformContext(modelName);
const claudeContext = createClaudeDownstreamContext();
let parsedUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
let parsedUsage: ReturnType<typeof parseProxyUsage> = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
};
const writeLines = (lines: string[]) => {
for (const line of lines) {
@@ -429,10 +436,25 @@ async function handleChatProxyRequest(
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
let billingDetails = await buildProxyBillingDetails({
site: selected.site,
account: selected.account,
modelName,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
billingDetails = null;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
@@ -450,6 +472,7 @@ async function handleChatProxyRequest(
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
billingDetails,
successfulUpstreamPath,
);
return;
@@ -593,10 +616,25 @@ async function handleChatProxyRequest(
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
let billingDetails = await buildProxyBillingDetails({
site: selected.site,
account: selected.account,
modelName,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
billingDetails = null;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
@@ -614,6 +652,7 @@ async function handleChatProxyRequest(
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
billingDetails,
successfulUpstreamPath,
);
return;
@@ -655,10 +694,25 @@ async function handleChatProxyRequest(
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
let billingDetails = await buildProxyBillingDetails({
site: selected.site,
account: selected.account,
modelName,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
billingDetails = null;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
@@ -676,6 +730,7 @@ async function handleChatProxyRequest(
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
billingDetails,
successfulUpstreamPath,
);
@@ -717,6 +772,7 @@ async function logProxy(
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
billingDetails: unknown = null,
upstreamPath: string | null = null,
) {
try {
@@ -739,6 +795,7 @@ async function logProxy(
completionTokens,
totalTokens,
estimatedCost,
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
+43 -4
View File
@@ -5,7 +5,7 @@ import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
import { isTokenExpiredError } from '../../services/alertRules.js';
import { estimateProxyCost } from '../../services/modelPricingService.js';
import { buildProxyBillingDetails, estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js';
@@ -106,7 +106,14 @@ export async function completionsProxyRoute(app: FastifyInstance) {
}
const decoder = new TextDecoder();
let parsedUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
let parsedUsage: ReturnType<typeof parseProxyUsage> = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
};
let sseBuffer = '';
try {
while (true) {
@@ -160,15 +167,30 @@ export async function completionsProxyRoute(app: FastifyInstance) {
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
let billingDetails = await buildProxyBillingDetails({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
billingDetails = null;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
);
return;
}
@@ -198,16 +220,31 @@ export async function completionsProxyRoute(app: FastifyInstance) {
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
let billingDetails = await buildProxyBillingDetails({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
billingDetails = null;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
);
return reply.send(data);
} catch (err: any) {
@@ -241,6 +278,7 @@ async function logProxy(
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
billingDetails: unknown = null,
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
@@ -261,6 +299,7 @@ async function logProxy(
completionTokens,
totalTokens,
estimatedCost,
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
+19 -2
View File
@@ -5,7 +5,7 @@ import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
import { isTokenExpiredError } from '../../services/alertRules.js';
import { estimateProxyCost } from '../../services/modelPricingService.js';
import { buildProxyBillingDetails, estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
@@ -115,16 +115,31 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
let billingDetails = await buildProxyBillingDetails({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
billingDetails = null;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', upstream.status, latency, null, retryCount,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
);
return reply.code(upstream.status).send(data);
} catch (err: any) {
@@ -156,6 +171,7 @@ async function logProxy(
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
billingDetails: unknown = null,
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
@@ -176,6 +192,7 @@ async function logProxy(
completionTokens,
totalTokens,
estimatedCost,
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
+44 -9
View File
@@ -5,7 +5,7 @@ import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
import { isTokenExpiredError } from '../../services/alertRules.js';
import { estimateProxyCost } from '../../services/modelPricingService.js';
import { buildProxyBillingDetails, estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
@@ -571,11 +571,7 @@ function convertResponsesBodyToOpenAiBody(
return payload;
}
type UsageSummary = {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
type UsageSummary = ReturnType<typeof parseProxyUsage>;
type ResponsesToolCall = {
id: string;
@@ -1358,7 +1354,14 @@ export async function responsesProxyRoute(app: FastifyInstance) {
}
const decoder = new TextDecoder();
let parsedUsage: UsageSummary = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
let parsedUsage: UsageSummary = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
};
let sseBuffer = '';
const passthroughResponsesStream = successfulUpstreamPath === '/v1/responses';
@@ -1479,15 +1482,30 @@ export async function responsesProxyRoute(app: FastifyInstance) {
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
let billingDetails = await buildProxyBillingDetails({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
billingDetails = null;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
successfulUpstreamPath,
);
return;
@@ -1530,16 +1548,31 @@ export async function responsesProxyRoute(app: FastifyInstance) {
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
let billingDetails = await buildProxyBillingDetails({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
billingDetails = null;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
successfulUpstreamPath,
);
return reply.send(downstreamData);
@@ -1575,6 +1608,7 @@ async function logProxy(
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
billingDetails: unknown = null,
upstreamPath: string | null = null,
) {
try {
@@ -1597,6 +1631,7 @@ async function logProxy(
completionTokens,
totalTokens,
estimatedCost,
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
@@ -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 } {
-1
View File
@@ -74,7 +74,6 @@ export const zhToEnSupplemental: Record<string, string> = {
'站点未开启签到': 'Check-in is not enabled on this site',
'无需重试(非故障)': 'No retry needed (not a failure)',
'该站点未提供签到端点,账号会被自动跳过。': 'This site does not expose a check-in endpoint, so the account is skipped automatically.',
'婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}': '婵炴垶铸鎸哥粔颢综合极椤曗偓nan炴劖鎷呴悜榯cum殸濡ょ姷草涢崯鑳亹? ${site.platform}',
'超时': 'time out',
'成本权重': 'cost weight',
'成功': 'success',
+62 -17
View File
@@ -29,6 +29,12 @@ function ChartFallback({ height = 280 }: { height?: number }) {
);
}
type SiteSpeedState =
| { status: 'loading' }
| { status: 'timeout' }
| { status: 'done'; ms: number }
| undefined;
export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminName?: string }) {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
@@ -38,10 +44,17 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
const [siteTrend, setSiteTrend] = useState<any[]>([]);
const [siteLoading, setSiteLoading] = useState(true);
const [sites, setSites] = useState<any[]>([]);
const [siteSpeedStates, setSiteSpeedStates] = useState<Record<string, SiteSpeedState>>({});
const [trendDays, setTrendDays] = useState(7);
const toast = useToast();
const normalizedAdminName = (adminName || '').trim() || '\u7ba1\u7406\u5458';
const getSiteSpeedKey = (site: any, idx: number) => String(site?.id ?? idx);
const setSiteSpeedState = (siteKey: string, nextState: SiteSpeedState) => {
setSiteSpeedStates((current) => ({ ...current, [siteKey]: nextState }));
};
const load = useCallback(async (silent = false) => {
if (!silent) setLoading(true);
else setRefreshing(true);
@@ -72,6 +85,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
setSiteTrend(trendRes.trend || []);
const siteRows = Array.isArray(sitesRes) ? sitesRes : (sitesRes?.sites || []);
setSites(siteRows.filter((site: any) => site?.status !== 'disabled'));
setSiteSpeedStates({});
} catch (err) {
console.error('Failed to load site stats:', err);
} finally {
@@ -196,8 +210,8 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
const proxy24hTotal = safeNumber(data?.proxy24h?.total);
const totalTokens = safeNumber(data?.proxy24h?.totalTokens);
const latencyDot = (ms: number) => {
const color = ms <= 500
const getLatencyColor = (ms: number) => (
ms <= 500
? 'var(--color-success)'
: ms <= 1000
? 'color-mix(in srgb, var(--color-success) 60%, var(--color-warning))'
@@ -207,8 +221,42 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
? 'color-mix(in srgb, var(--color-warning) 60%, var(--color-danger))'
: ms < 3000
? 'color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))'
: 'var(--color-danger)';
return `<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:${color};box-shadow:0 0 4px ${color};animation:pulse 1.5s ease-in-out infinite;margin-right:3px;vertical-align:middle"></span><span style="color:${color};font-weight:600">${ms}ms</span>`;
: 'var(--color-danger)'
);
const renderSiteSpeedLabel = (site: any, idx: number) => {
const siteKey = getSiteSpeedKey(site, idx);
const speedState = siteSpeedStates[siteKey];
if (!speedState || speedState.status === 'loading') {
return speedState ? '...' : '测速';
}
if (speedState.status === 'timeout') {
return '超时';
}
const ms = speedState.ms;
const color = getLatencyColor(ms);
return (
<>
<span
style={{
display: 'inline-block',
width: 6,
height: 6,
borderRadius: '50%',
background: color,
boxShadow: `0 0 4px ${color}`,
animation: 'pulse 1.5s ease-in-out infinite',
marginRight: 3,
verticalAlign: 'middle',
}}
/>
<span style={{ color, fontWeight: 600 }}>{ms}ms</span>
</>
);
};
return (
@@ -406,19 +454,16 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
className="btn btn-ghost"
style={{ fontSize: 11, padding: '3px 10px', border: '1px solid var(--color-border)', borderRadius: 6, display: 'inline-flex', alignItems: 'center', gap: 4 }}
onClick={async () => {
for (const s of sites) {
const el = document.getElementById(`speed-${s.id}`);
if (el) el.textContent = '...';
}
await Promise.all(sites.map(async (s: any) => {
const el = document.getElementById(`speed-${s.id}`);
await Promise.all(sites.map(async (s: any, idx: number) => {
const siteKey = getSiteSpeedKey(s, idx);
setSiteSpeedState(siteKey, { status: 'loading' });
try {
const start = performance.now();
await fetch(`${s.url}/v1/models`, { method: 'GET', mode: 'no-cors' });
const ms = Math.round(performance.now() - start);
if (el) el.innerHTML = latencyDot(ms);
setSiteSpeedState(siteKey, { status: 'done', ms });
} catch {
if (el) el.textContent = '超时';
setSiteSpeedState(siteKey, { status: 'timeout' });
}
}));
toast.success('全部测速完成');
@@ -439,22 +484,22 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
className="btn btn-ghost"
style={{ fontSize: 11, padding: '2px 8px', border: '1px solid var(--color-border)', borderRadius: 6, display: 'inline-flex', alignItems: 'center', gap: 3 }}
onClick={async () => {
const btn = document.getElementById(`speed-${site.id || idx}`);
if (btn) btn.textContent = '...';
const siteKey = getSiteSpeedKey(site, idx);
setSiteSpeedState(siteKey, { status: 'loading' });
try {
const start = performance.now();
await fetch(`${site.url}/v1/models`, { method: 'GET', mode: 'no-cors' });
const ms = Math.round(performance.now() - start);
if (btn) btn.innerHTML = latencyDot(ms);
setSiteSpeedState(siteKey, { status: 'done', ms });
toast.success(`${site.name}: ${ms}ms`);
} catch {
if (btn) btn.textContent = '超时';
setSiteSpeedState(siteKey, { status: 'timeout' });
toast.error(`${site.name}: 测速失败`);
}
}}
>
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
<span id={`speed-${site.id || idx}`}>测速</span>
<span>{renderSiteSpeedLabel(site, idx)}</span>
</button>
<a
href={site.url}
+113 -2
View File
@@ -26,6 +26,36 @@ interface ProxyLog {
promptTokens?: number;
completionTokens?: number;
estimatedCost?: number;
billingDetails?: {
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;
};
} | null;
}
const PAGE_SIZES = [20, 50, 100];
@@ -52,6 +82,57 @@ function latencyBgColor(ms: number) {
return 'color-mix(in srgb, var(--color-success) 12%, transparent)';
}
function formatCompactNumber(value: number, digits = 6) {
if (!Number.isFinite(value)) return '0';
const formatted = value.toFixed(digits).replace(/\.?0+$/, '');
return formatted || '0';
}
function formatPerMillionPrice(value: number) {
return `$${formatCompactNumber(value)} / 1M tokens`;
}
function formatBillingDetailSummary(log: ProxyLog) {
const detail = log.billingDetails;
if (!detail) return null;
return `模型倍率 ${formatCompactNumber(detail.pricing.modelRatio)},输出倍率 ${formatCompactNumber(detail.pricing.completionRatio)},缓存倍率 ${formatCompactNumber(detail.pricing.cacheRatio)},缓存创建倍率 ${formatCompactNumber(detail.pricing.cacheCreationRatio)},分组倍率 ${formatCompactNumber(detail.pricing.groupRatio)}`;
}
function buildBillingProcessLines(log: ProxyLog) {
const detail = log.billingDetails;
if (!detail) return [];
const lines = [
`提示价格:${formatPerMillionPrice(detail.breakdown.inputPerMillion)}`,
`补全价格:${formatPerMillionPrice(detail.breakdown.outputPerMillion)}`,
];
if (detail.usage.cacheReadTokens > 0) {
lines.push(`缓存价格:${formatPerMillionPrice(detail.breakdown.cacheReadPerMillion)} (缓存倍率: ${formatCompactNumber(detail.pricing.cacheRatio)})`);
}
if (detail.usage.cacheCreationTokens > 0) {
lines.push(`缓存创建价格:${formatPerMillionPrice(detail.breakdown.cacheCreationPerMillion)} (缓存创建倍率: ${formatCompactNumber(detail.pricing.cacheCreationRatio)})`);
}
const parts = [
`提示 ${detail.usage.billablePromptTokens.toLocaleString()} tokens / 1M tokens * $${formatCompactNumber(detail.breakdown.inputPerMillion)}`,
];
if (detail.usage.cacheReadTokens > 0) {
parts.push(`缓存 ${detail.usage.cacheReadTokens.toLocaleString()} tokens / 1M tokens * $${formatCompactNumber(detail.breakdown.cacheReadPerMillion)}`);
}
if (detail.usage.cacheCreationTokens > 0) {
parts.push(`缓存创建 ${detail.usage.cacheCreationTokens.toLocaleString()} tokens / 1M tokens * $${formatCompactNumber(detail.breakdown.cacheCreationPerMillion)}`);
}
parts.push(`补全 ${detail.usage.completionTokens.toLocaleString()} tokens / 1M tokens * $${formatCompactNumber(detail.breakdown.outputPerMillion)} = $${detail.breakdown.totalCost.toFixed(6)}`);
lines.push(parts.join(' + '));
return lines;
}
export default function ProxyLogs() {
const [logs, setLogs] = useState<ProxyLog[]>([]);
const [loading, setLoading] = useState(true);
@@ -196,6 +277,8 @@ export default function ProxyLogs() {
<tbody>
{paged.map(log => {
const pathMeta = parseProxyLogPathMeta(log.errorMessage);
const billingDetailSummary = formatBillingDetailSummary(log);
const billingProcessLines = buildBillingProcessLines(log);
return (
<React.Fragment key={log.id}>
<tr
@@ -276,7 +359,8 @@ export default function ProxyLogs() {
{/* 日志详情 section */}
<div style={{ display: 'flex', gap: 6 }}>
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>日志详情</span>
<span>
<div>
<div>
请求模型: <strong style={{ color: 'var(--color-text-primary)' }}>{log.modelRequested}</strong>
{log.modelActual && log.modelActual !== log.modelRequested && (
<> → 实际模型: <strong style={{ color: 'var(--color-text-primary)' }}>{log.modelActual}</strong></>
@@ -285,12 +369,38 @@ export default function ProxyLogs() {
,用时 <strong style={{ color: latencyColor(log.latencyMs) }}>{formatLatency(log.latencyMs)}</strong>
,站点: <strong style={{ color: 'var(--color-text-primary)' }}>{log.siteName || '未知站点'}</strong>
,账号: <strong style={{ color: 'var(--color-text-primary)' }}>{log.username || '未知账号'}</strong>
</span>
</div>
{billingDetailSummary && (
<div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>
)}
</div>
</div>
{log.billingDetails && log.billingDetails.usage.cacheReadTokens > 0 && (
<div style={{ display: 'flex', gap: 6 }}>
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>缓存 Tokens</span>
<span>{log.billingDetails.usage.cacheReadTokens.toLocaleString()}</span>
</div>
)}
{log.billingDetails && log.billingDetails.usage.cacheCreationTokens > 0 && (
<div style={{ display: 'flex', gap: 6 }}>
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>缓存创建 Tokens</span>
<span>{log.billingDetails.usage.cacheCreationTokens.toLocaleString()}</span>
</div>
)}
{/* 计费过程 section */}
<div style={{ display: 'flex', gap: 6 }}>
<span style={{ fontWeight: 600, color: 'var(--color-info)', flexShrink: 0 }}>计费过程</span>
{billingProcessLines.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{billingProcessLines.map((line, index) => (
<span key={`${log.id}-billing-${index}`}>{line}</span>
))}
<span style={{ color: 'var(--color-text-muted)' }}>仅供参考,以实际扣费为准</span>
</div>
) : (
<span>
输入 {log.promptTokens?.toLocaleString() || 0} tokens
{' + '}输出 {log.completionTokens?.toLocaleString() || 0} tokens
@@ -299,6 +409,7 @@ export default function ProxyLogs() {
<>,预估费用 <strong style={{ color: 'var(--color-text-primary)' }}>${log.estimatedCost.toFixed(6)}</strong></>
)}
</span>
)}
</div>
{/* 下游请求路径 */}
@@ -48,6 +48,7 @@ describe('Accounts rebind panel focus', () => {
balanceUsed: 0,
todayReward: 0,
todaySpend: 0,
accessToken: 'session-token',
status: 'expired',
checkinEnabled: true,
siteId: 10,
@@ -84,7 +84,7 @@ describe('Dashboard site speed buttons', () => {
const speedButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('娴嬮€?')
&& collectText(node).trim() === '测速'
));
await act(async () => {
@@ -97,4 +97,34 @@ describe('Dashboard site speed buttons', () => {
root?.unmount();
}
});
it('updates bulk site speed status without imperative document lookups', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<Dashboard />
</ToastProvider>,
);
});
await flushMicrotasks();
const bulkSpeedButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '一键测速'
));
await act(async () => {
await bulkSpeedButton.props.onClick();
});
await flushMicrotasks();
expect(globalThis.document.getElementById).not.toHaveBeenCalled();
} finally {
root?.unmount();
}
});
});