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:
@@ -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;
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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'))`),
|
||||
|
||||
+249
-247
@@ -26,12 +26,12 @@ import {
|
||||
} from '../../services/accountHealthService.js';
|
||||
import { appendSessionTokenRebindHint } from '../../services/alertRules.js';
|
||||
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||
|
||||
type AccountWithSiteRow = {
|
||||
accounts: typeof schema.accounts.$inferSelect;
|
||||
sites: typeof schema.sites.$inferSelect;
|
||||
};
|
||||
|
||||
|
||||
type AccountWithSiteRow = {
|
||||
accounts: typeof schema.accounts.$inferSelect;
|
||||
sites: typeof schema.sites.$inferSelect;
|
||||
};
|
||||
|
||||
type AccountHealthRefreshResult = {
|
||||
accountId: number;
|
||||
username: string | null;
|
||||
@@ -159,18 +159,18 @@ function normalizeLoginFailure(message: string | null | undefined): LoginFailure
|
||||
message: raw || 'login failed',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function summarizeAccountHealthRefresh(results: AccountHealthRefreshResult[]) {
|
||||
return {
|
||||
total: results.length,
|
||||
healthy: results.filter((item) => item.state === 'healthy').length,
|
||||
unhealthy: results.filter((item) => item.state === 'unhealthy').length,
|
||||
degraded: results.filter((item) => item.state === 'degraded').length,
|
||||
disabled: results.filter((item) => item.state === 'disabled').length,
|
||||
unknown: results.filter((item) => item.state === 'unknown').length,
|
||||
success: results.filter((item) => item.status === 'success').length,
|
||||
failed: results.filter((item) => item.status === 'failed').length,
|
||||
skipped: results.filter((item) => item.status === 'skipped').length,
|
||||
total: results.length,
|
||||
healthy: results.filter((item) => item.state === 'healthy').length,
|
||||
unhealthy: results.filter((item) => item.state === 'unhealthy').length,
|
||||
degraded: results.filter((item) => item.state === 'degraded').length,
|
||||
disabled: results.filter((item) => item.state === 'disabled').length,
|
||||
unknown: results.filter((item) => item.state === 'unknown').length,
|
||||
success: results.filter((item) => item.status === 'success').length,
|
||||
failed: results.filter((item) => item.status === 'failed').length,
|
||||
skipped: results.filter((item) => item.status === 'skipped').length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,18 +235,18 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
|
||||
const capabilities = buildCapabilitiesForAccount(row.accounts);
|
||||
|
||||
if ((row.accounts.status || 'active') === 'disabled' || (row.sites.status || 'active') === 'disabled') {
|
||||
setAccountRuntimeHealth(accountId, {
|
||||
state: 'disabled',
|
||||
reason: '账号或站点已禁用',
|
||||
source: 'health-refresh',
|
||||
});
|
||||
return {
|
||||
accountId,
|
||||
username,
|
||||
siteName,
|
||||
status: 'skipped',
|
||||
state: 'disabled',
|
||||
message: '账号或站点已禁用',
|
||||
setAccountRuntimeHealth(accountId, {
|
||||
state: 'disabled',
|
||||
reason: '账号或站点已禁用',
|
||||
source: 'health-refresh',
|
||||
});
|
||||
return {
|
||||
accountId,
|
||||
username,
|
||||
siteName,
|
||||
status: 'skipped',
|
||||
state: 'disabled',
|
||||
message: '账号或站点已禁用',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -285,46 +285,46 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
|
||||
state: runtimeHealth.state,
|
||||
message: runtimeHealth.reason,
|
||||
};
|
||||
} catch (error: any) {
|
||||
const message = String(error?.message || '健康检查失败');
|
||||
setAccountRuntimeHealth(accountId, {
|
||||
state: 'unhealthy',
|
||||
reason: message,
|
||||
source: 'health-refresh',
|
||||
});
|
||||
return {
|
||||
accountId,
|
||||
username,
|
||||
siteName,
|
||||
status: 'failed',
|
||||
state: 'unhealthy',
|
||||
message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function executeRefreshAccountRuntimeHealth(accountId?: number) {
|
||||
const rows = await db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.all();
|
||||
|
||||
const targetRows = Number.isFinite(accountId as number)
|
||||
? rows.filter((row) => row.accounts.id === accountId)
|
||||
: rows;
|
||||
|
||||
const results: AccountHealthRefreshResult[] = [];
|
||||
for (const row of targetRows) {
|
||||
results.push(await refreshRuntimeHealthForRow(row));
|
||||
}
|
||||
|
||||
return {
|
||||
summary: summarizeAccountHealthRefresh(results),
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
export async function accountsRoutes(app: FastifyInstance) {
|
||||
// List all accounts (with site info)
|
||||
} catch (error: any) {
|
||||
const message = String(error?.message || '健康检查失败');
|
||||
setAccountRuntimeHealth(accountId, {
|
||||
state: 'unhealthy',
|
||||
reason: message,
|
||||
source: 'health-refresh',
|
||||
});
|
||||
return {
|
||||
accountId,
|
||||
username,
|
||||
siteName,
|
||||
status: 'failed',
|
||||
state: 'unhealthy',
|
||||
message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function executeRefreshAccountRuntimeHealth(accountId?: number) {
|
||||
const rows = await db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.all();
|
||||
|
||||
const targetRows = Number.isFinite(accountId as number)
|
||||
? rows.filter((row) => row.accounts.id === accountId)
|
||||
: rows;
|
||||
|
||||
const results: AccountHealthRefreshResult[] = [];
|
||||
for (const row of targetRows) {
|
||||
results.push(await refreshRuntimeHealthForRow(row));
|
||||
}
|
||||
|
||||
return {
|
||||
summary: summarizeAccountHealthRefresh(results),
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
export async function accountsRoutes(app: FastifyInstance) {
|
||||
// List all accounts (with site info)
|
||||
app.get('/api/accounts', async () => {
|
||||
const rows = await db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)).all();
|
||||
@@ -398,19 +398,19 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Login to a site and auto-create account
|
||||
app.post<{ Body: { siteId: number; username: string; password: string } }>('/api/accounts/login', async (request) => {
|
||||
const { siteId, username, password } = request.body;
|
||||
|
||||
// Get site info
|
||||
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
|
||||
if (!site) return { success: false, message: 'site not found' };
|
||||
|
||||
// Get platform adapter
|
||||
const adapter = getAdapter(site.platform);
|
||||
if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
|
||||
|
||||
|
||||
// Login to a site and auto-create account
|
||||
app.post<{ Body: { siteId: number; username: string; password: string } }>('/api/accounts/login', async (request) => {
|
||||
const { siteId, username, password } = request.body;
|
||||
|
||||
// Get site info
|
||||
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
|
||||
if (!site) return { success: false, message: 'site not found' };
|
||||
|
||||
// Get platform adapter
|
||||
const adapter = getAdapter(site.platform);
|
||||
if (!adapter) return { success: false, message: `不支持的平台: ${site.platform}` };
|
||||
|
||||
// Login to the target site
|
||||
const loginResult = await adapter.login(site.url, username, password);
|
||||
if (!loginResult.success || !loginResult.accessToken) {
|
||||
@@ -421,24 +421,24 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
message: normalizedFailure.message,
|
||||
};
|
||||
}
|
||||
|
||||
const guessedPlatformUserId = guessPlatformUserIdFromUsername(username);
|
||||
|
||||
// Auto-fetch API token(s)
|
||||
let apiToken: string | null = null;
|
||||
let apiTokens: Array<{ name?: string | null; key?: string | null; enabled?: boolean | null }> = [];
|
||||
try {
|
||||
apiToken = await adapter.getApiToken(site.url, loginResult.accessToken, guessedPlatformUserId);
|
||||
} catch { }
|
||||
try {
|
||||
apiTokens = await adapter.getApiTokens(site.url, loginResult.accessToken, guessedPlatformUserId);
|
||||
} catch { }
|
||||
|
||||
const preferredApiToken = apiTokens.find((token) => token.enabled !== false && token.key)?.key || apiToken || null;
|
||||
const existing = await db.select().from(schema.accounts)
|
||||
.where(and(eq(schema.accounts.siteId, siteId), eq(schema.accounts.username, username)))
|
||||
.get();
|
||||
|
||||
|
||||
const guessedPlatformUserId = guessPlatformUserIdFromUsername(username);
|
||||
|
||||
// Auto-fetch API token(s)
|
||||
let apiToken: string | null = null;
|
||||
let apiTokens: Array<{ name?: string | null; key?: string | null; enabled?: boolean | null }> = [];
|
||||
try {
|
||||
apiToken = await adapter.getApiToken(site.url, loginResult.accessToken, guessedPlatformUserId);
|
||||
} catch { }
|
||||
try {
|
||||
apiTokens = await adapter.getApiTokens(site.url, loginResult.accessToken, guessedPlatformUserId);
|
||||
} catch { }
|
||||
|
||||
const preferredApiToken = apiTokens.find((token) => token.enabled !== false && token.key)?.key || apiToken || null;
|
||||
const existing = await db.select().from(schema.accounts)
|
||||
.where(and(eq(schema.accounts.siteId, siteId), eq(schema.accounts.username, username)))
|
||||
.get();
|
||||
|
||||
const extraConfigPatch: Record<string, unknown> = {
|
||||
credentialMode: 'session',
|
||||
autoRelogin: {
|
||||
@@ -446,24 +446,24 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
passwordCipher: encryptAccountPassword(password),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
if (guessedPlatformUserId) {
|
||||
extraConfigPatch.platformUserId = guessedPlatformUserId;
|
||||
}
|
||||
const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, extraConfigPatch);
|
||||
|
||||
// Create or update account
|
||||
let accountId = existing?.id;
|
||||
if (existing) {
|
||||
await db.update(schema.accounts).set({
|
||||
accessToken: loginResult.accessToken,
|
||||
apiToken: preferredApiToken || undefined,
|
||||
checkinEnabled: true,
|
||||
status: 'active',
|
||||
extraConfig,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.accounts.id, existing.id)).run();
|
||||
} else {
|
||||
};
|
||||
if (guessedPlatformUserId) {
|
||||
extraConfigPatch.platformUserId = guessedPlatformUserId;
|
||||
}
|
||||
const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, extraConfigPatch);
|
||||
|
||||
// Create or update account
|
||||
let accountId = existing?.id;
|
||||
if (existing) {
|
||||
await db.update(schema.accounts).set({
|
||||
accessToken: loginResult.accessToken,
|
||||
apiToken: preferredApiToken || undefined,
|
||||
checkinEnabled: true,
|
||||
status: 'active',
|
||||
extraConfig,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.accounts.id, existing.id)).run();
|
||||
} else {
|
||||
const inserted = await db.insert(schema.accounts).values({
|
||||
siteId,
|
||||
username,
|
||||
@@ -477,39 +477,39 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
const insertedId = Number(inserted.lastInsertRowid || 0);
|
||||
accountId = insertedId > 0 ? insertedId : undefined;
|
||||
}
|
||||
|
||||
const result = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId!)).get();
|
||||
if (!result) {
|
||||
return { success: false, message: 'account create failed' };
|
||||
}
|
||||
|
||||
if (apiTokens.length > 0) {
|
||||
try {
|
||||
|
||||
const result = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId!)).get();
|
||||
if (!result) {
|
||||
return { success: false, message: 'account create failed' };
|
||||
}
|
||||
|
||||
if (apiTokens.length > 0) {
|
||||
try {
|
||||
await syncTokensFromUpstream(result.id, apiTokens);
|
||||
} catch { }
|
||||
} else if (preferredApiToken) {
|
||||
try {
|
||||
} catch { }
|
||||
} else if (preferredApiToken) {
|
||||
try {
|
||||
await ensureDefaultTokenForAccount(result.id, preferredApiToken, { name: 'default', source: 'sync' });
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// Auto-refresh balance
|
||||
try { await refreshBalance(result.id); } catch { }
|
||||
try {
|
||||
await refreshModelsForAccount(result.id);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// Auto-refresh balance
|
||||
try { await refreshBalance(result.id); } catch { }
|
||||
try {
|
||||
await refreshModelsForAccount(result.id);
|
||||
await rebuildTokenRoutesFromAvailability();
|
||||
} catch { }
|
||||
|
||||
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
apiTokenFound: !!preferredApiToken,
|
||||
tokenCount: apiTokens.length,
|
||||
reusedAccount: !!existing,
|
||||
};
|
||||
});
|
||||
|
||||
} catch { }
|
||||
|
||||
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
apiTokenFound: !!preferredApiToken,
|
||||
tokenCount: apiTokens.length,
|
||||
reusedAccount: !!existing,
|
||||
};
|
||||
});
|
||||
|
||||
// Verify credentials against a site.
|
||||
app.post<{ Body: { siteId: number; accessToken: string; platformUserId?: number; credentialMode?: AccountCredentialMode } }>('/api/accounts/verify-token', async (request) => {
|
||||
const { siteId, platformUserId } = request.body;
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -1094,7 +1097,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
usernameDetected: !!(!body.username && username),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// Update an account
|
||||
app.put<{ Params: { id: string }; Body: any }>('/api/accounts/:id', async (request, reply) => {
|
||||
const id = parseInt(request.params.id);
|
||||
@@ -1157,98 +1160,97 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
|
||||
|
||||
if (typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
|
||||
try {
|
||||
|
||||
if (typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
|
||||
try {
|
||||
await ensureDefaultTokenForAccount(id, updates.apiToken, { name: 'default', source: 'manual' });
|
||||
} catch { }
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshModelsForAccount(id);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshModelsForAccount(id);
|
||||
await rebuildTokenRoutesFromAvailability();
|
||||
} catch { }
|
||||
|
||||
return await db.select().from(schema.accounts).where(eq(schema.accounts.id, id)).get();
|
||||
});
|
||||
|
||||
// Delete an account
|
||||
app.delete<{ Params: { id: string } }>('/api/accounts/:id', async (request) => {
|
||||
const id = parseInt(request.params.id);
|
||||
await db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run();
|
||||
try {
|
||||
} catch { }
|
||||
|
||||
return await db.select().from(schema.accounts).where(eq(schema.accounts.id, id)).get();
|
||||
});
|
||||
|
||||
// Delete an account
|
||||
app.delete<{ Params: { id: string } }>('/api/accounts/:id', async (request) => {
|
||||
const id = parseInt(request.params.id);
|
||||
await db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run();
|
||||
try {
|
||||
await rebuildTokenRoutesFromAvailability();
|
||||
} catch { }
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
app.post<{ Body?: { accountId?: number; wait?: boolean } }>('/api/accounts/health/refresh', async (request, reply) => {
|
||||
const rawAccountId = request.body?.accountId as unknown;
|
||||
const hasAccountId = rawAccountId !== undefined && rawAccountId !== null && String(rawAccountId).trim() !== '';
|
||||
const accountId = hasAccountId ? Number.parseInt(String(rawAccountId), 10) : undefined;
|
||||
const wait = request.body?.wait === true;
|
||||
|
||||
if (hasAccountId && (!Number.isFinite(accountId) || (accountId as number) <= 0)) {
|
||||
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
|
||||
}
|
||||
|
||||
if (wait) {
|
||||
const result = await executeRefreshAccountRuntimeHealth(accountId);
|
||||
if (accountId && result.summary.total === 0) {
|
||||
return reply.code(404).send({ success: false, message: '账号不存在' });
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
const taskTitle = accountId ? `刷新账号运行健康状态 #${accountId}` : '刷新全部账号运行健康状态';
|
||||
const dedupeKey = accountId ? `refresh-account-runtime-health-${accountId}` : 'refresh-all-account-runtime-health';
|
||||
|
||||
const { task, reused } = startBackgroundTask(
|
||||
{
|
||||
type: 'status',
|
||||
title: taskTitle,
|
||||
dedupeKey,
|
||||
notifyOnFailure: true,
|
||||
successMessage: (currentTask) => {
|
||||
const summary = (currentTask.result as { summary?: ReturnType<typeof summarizeAccountHealthRefresh> })?.summary;
|
||||
if (!summary) return `${taskTitle}已完成`;
|
||||
return `${taskTitle}完成:健康 ${summary.healthy},异常 ${summary.unhealthy},禁用 ${summary.disabled}`;
|
||||
},
|
||||
failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`,
|
||||
},
|
||||
async () => executeRefreshAccountRuntimeHealth(accountId),
|
||||
);
|
||||
|
||||
return reply.code(202).send({
|
||||
success: true,
|
||||
queued: true,
|
||||
reused,
|
||||
jobId: task.id,
|
||||
status: task.status,
|
||||
message: reused
|
||||
? '账号运行健康状态刷新进行中,请稍后查看账号列表'
|
||||
: '已开始刷新账号运行健康状态,请稍后查看账号列表',
|
||||
});
|
||||
});
|
||||
|
||||
// Refresh balance for an account
|
||||
app.post<{ Params: { id: string } }>('/api/accounts/:id/balance', async (request, reply) => {
|
||||
const id = parseInt(request.params.id);
|
||||
try {
|
||||
const result = await refreshBalance(id);
|
||||
if (!result) {
|
||||
reply.code(404);
|
||||
return { message: 'account not found or platform not supported' };
|
||||
}
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
reply.code(400);
|
||||
return { message: err?.message || 'failed to fetch balance' };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
} catch { }
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
app.post<{ Body?: { accountId?: number; wait?: boolean } }>('/api/accounts/health/refresh', async (request, reply) => {
|
||||
const rawAccountId = request.body?.accountId as unknown;
|
||||
const hasAccountId = rawAccountId !== undefined && rawAccountId !== null && String(rawAccountId).trim() !== '';
|
||||
const accountId = hasAccountId ? Number.parseInt(String(rawAccountId), 10) : undefined;
|
||||
const wait = request.body?.wait === true;
|
||||
|
||||
if (hasAccountId && (!Number.isFinite(accountId) || (accountId as number) <= 0)) {
|
||||
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
|
||||
}
|
||||
|
||||
if (wait) {
|
||||
const result = await executeRefreshAccountRuntimeHealth(accountId);
|
||||
if (accountId && result.summary.total === 0) {
|
||||
return reply.code(404).send({ success: false, message: '账号不存在' });
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
const taskTitle = accountId ? `刷新账号运行健康状态 #${accountId}` : '刷新全部账号运行健康状态';
|
||||
const dedupeKey = accountId ? `refresh-account-runtime-health-${accountId}` : 'refresh-all-account-runtime-health';
|
||||
|
||||
const { task, reused } = startBackgroundTask(
|
||||
{
|
||||
type: 'status',
|
||||
title: taskTitle,
|
||||
dedupeKey,
|
||||
notifyOnFailure: true,
|
||||
successMessage: (currentTask) => {
|
||||
const summary = (currentTask.result as { summary?: ReturnType<typeof summarizeAccountHealthRefresh> })?.summary;
|
||||
if (!summary) return `${taskTitle}已完成`;
|
||||
return `${taskTitle}完成:健康 ${summary.healthy},异常 ${summary.unhealthy},禁用 ${summary.disabled}`;
|
||||
},
|
||||
failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`,
|
||||
},
|
||||
async () => executeRefreshAccountRuntimeHealth(accountId),
|
||||
);
|
||||
|
||||
return reply.code(202).send({
|
||||
success: true,
|
||||
queued: true,
|
||||
reused,
|
||||
jobId: task.id,
|
||||
status: task.status,
|
||||
message: reused
|
||||
? '账号运行健康状态刷新进行中,请稍后查看账号列表'
|
||||
: '已开始刷新账号运行健康状态,请稍后查看账号列表',
|
||||
});
|
||||
});
|
||||
|
||||
// Refresh balance for an account
|
||||
app.post<{ Params: { id: string } }>('/api/accounts/:id/balance', async (request, reply) => {
|
||||
const id = parseInt(request.params.id);
|
||||
try {
|
||||
const result = await refreshBalance(id);
|
||||
if (!result) {
|
||||
reply.code(404);
|
||||
return { message: 'account not found or platform not supported' };
|
||||
}
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
reply.code(400);
|
||||
return { message: err?.message || 'failed to fetch balance' };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,18 +220,33 @@ 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);
|
||||
return reply.send(data);
|
||||
} catch (err: any) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount);
|
||||
@@ -237,10 +274,11 @@ async function logProxy(
|
||||
latencyMs: number,
|
||||
errorMessage: string | null,
|
||||
retryCount: number,
|
||||
promptTokens = 0,
|
||||
completionTokens = 0,
|
||||
totalTokens = 0,
|
||||
estimatedCost = 0,
|
||||
promptTokens = 0,
|
||||
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,
|
||||
|
||||
@@ -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,18 +115,33 @@ 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);
|
||||
return reply.code(upstream.status).send(data);
|
||||
} catch (err: any) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount);
|
||||
@@ -152,10 +167,11 @@ async function logProxy(
|
||||
latencyMs: number,
|
||||
errorMessage: string | null,
|
||||
retryCount: number,
|
||||
promptTokens = 0,
|
||||
completionTokens = 0,
|
||||
totalTokens = 0,
|
||||
estimatedCost = 0,
|
||||
promptTokens = 0,
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
+128
-17
@@ -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,29 +359,57 @@ export default function ProxyLogs() {
|
||||
{/* 日志详情 section */}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>日志详情</span>
|
||||
<span>
|
||||
请求模型: <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></>
|
||||
<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></>
|
||||
)}
|
||||
,状态: <strong style={{ color: log.status === 'success' ? 'var(--color-success)' : 'var(--color-danger)' }}>{log.status === 'success' ? '成功' : '失败'}</strong>
|
||||
,用时 <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>
|
||||
</div>
|
||||
{billingDetailSummary && (
|
||||
<div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>
|
||||
)}
|
||||
,状态: <strong style={{ color: log.status === 'success' ? 'var(--color-success)' : 'var(--color-danger)' }}>{log.status === 'success' ? '成功' : '失败'}</strong>
|
||||
,用时 <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>
|
||||
</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>
|
||||
<span>
|
||||
输入 {log.promptTokens?.toLocaleString() || 0} tokens
|
||||
{' + '}输出 {log.completionTokens?.toLocaleString() || 0} tokens
|
||||
{' = '}总计 {log.totalTokens?.toLocaleString() || 0} tokens
|
||||
{typeof log.estimatedCost === 'number' && (
|
||||
<>,预估费用 <strong style={{ color: 'var(--color-text-primary)' }}>${log.estimatedCost.toFixed(6)}</strong></>
|
||||
)}
|
||||
</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
|
||||
{' = '}总计 {log.totalTokens?.toLocaleString() || 0} tokens
|
||||
{typeof log.estimatedCost === 'number' && (
|
||||
<>,预估费用 <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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user