From 55b1ae564eddd49727418eff5bb47e286cef1696 Mon Sep 17 00:00:00 2001 From: cita Date: Fri, 6 Mar 2026 17:10:01 +0800 Subject: [PATCH] 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 --- docs/plans/2026-03-06-encoding-cleanup.md | 99 ++++ drizzle/0005_proxy_log_billing_details.sql | 1 + src/encoding.mojibake.test.ts | 55 ++ src/server/db/index.ts | 12 + src/server/db/schema.ts | 1 + src/server/routes/api/accounts.ts | 496 +++++++++--------- src/server/routes/api/stats.ts | 11 + src/server/routes/proxy/chat.stream.test.ts | 3 + src/server/routes/proxy/chat.ts | 61 ++- src/server/routes/proxy/completions.ts | 57 +- src/server/routes/proxy/embeddings.ts | 31 +- src/server/routes/proxy/responses.ts | 53 +- .../services/databaseMigrationService.ts | 10 +- .../services/modelPricingService.test.ts | 87 ++- src/server/services/modelPricingService.ts | 212 +++++++- src/server/services/platforms/newApi.test.ts | 12 +- src/server/services/proxyUsageParser.test.ts | 56 +- src/server/services/proxyUsageParser.ts | 127 ++++- src/web/i18n.supplement.ts | 1 - src/web/pages/Dashboard.tsx | 79 ++- src/web/pages/ProxyLogs.tsx | 145 ++++- .../accounts.rebind-panel-focus.test.tsx | 1 + .../dashboard.site-speed-button.test.tsx | 32 +- 23 files changed, 1298 insertions(+), 344 deletions(-) create mode 100644 docs/plans/2026-03-06-encoding-cleanup.md create mode 100644 drizzle/0005_proxy_log_billing_details.sql create mode 100644 src/encoding.mojibake.test.ts diff --git a/docs/plans/2026-03-06-encoding-cleanup.md b/docs/plans/2026-03-06-encoding-cleanup.md new file mode 100644 index 0000000..842591b --- /dev/null +++ b/docs/plans/2026-03-06-encoding-cleanup.md @@ -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 diff --git a/drizzle/0005_proxy_log_billing_details.sql b/drizzle/0005_proxy_log_billing_details.sql new file mode 100644 index 0000000..5b7b114 --- /dev/null +++ b/drizzle/0005_proxy_log_billing_details.sql @@ -0,0 +1 @@ +ALTER TABLE `proxy_logs` ADD `billing_details` text; diff --git a/src/encoding.mojibake.test.ts b/src/encoding.mojibake.test.ts new file mode 100644 index 0000000..c015dac --- /dev/null +++ b/src/encoding.mojibake.test.ts @@ -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([]); + }); +}); diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 4e91b95..7c74e39 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -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), diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index a5563c6..a6d99d1 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -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'))`), diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index 0877c19..0e87aa6 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -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 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 = { 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 => { 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[] = [ { 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[] = [ { 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 })?.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 })?.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' }; + } + }); +} + diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index 6a12e1a..c5fbf34 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -25,6 +25,16 @@ function parseBooleanFlag(raw?: string): boolean { return normalized === '1' || normalized === 'true' || normalized === 'yes'; } +function parseProxyLogBillingDetails(value: unknown): Record | 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 : 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, diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index 1ac6b81..101da90 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -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 => 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(); diff --git a/src/server/routes/proxy/chat.ts b/src/server/routes/proxy/chat.ts index 00345d1..298cc45 100644 --- a/src/server/routes/proxy/chat.ts +++ b/src/server/routes/proxy/chat.ts @@ -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 = { + 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, diff --git a/src/server/routes/proxy/completions.ts b/src/server/routes/proxy/completions.ts index 9876b39..f2e584d 100644 --- a/src/server/routes/proxy/completions.ts +++ b/src/server/routes/proxy/completions.ts @@ -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 = { + 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, diff --git a/src/server/routes/proxy/embeddings.ts b/src/server/routes/proxy/embeddings.ts index fd60376..6e75f68 100644 --- a/src/server/routes/proxy/embeddings.ts +++ b/src/server/routes/proxy/embeddings.ts @@ -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, diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index 7d13a0e..3055e3c 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -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; 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, diff --git a/src/server/services/databaseMigrationService.ts b/src/server/services/databaseMigrationService.ts index 7a00d14..d9c5277 100644 --- a/src/server/services/databaseMigrationService.ts +++ b/src/server/services/databaseMigrationService.ts @@ -318,7 +318,7 @@ async function ensureSchema(client: SqlClient): Promise { `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 { `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 { `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), }; } - diff --git a/src/server/services/modelPricingService.test.ts b/src/server/services/modelPricingService.test.ts index 5ba8bb2..ef97d81 100644 --- a/src/server/services/modelPricingService.test.ts +++ b/src/server/services/modelPricingService.test.ts @@ -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); diff --git a/src/server/services/modelPricingService.ts b/src/server/services/modelPricingService.ts index 2be595f..03ce035 100644 --- a/src/server/services/modelPricingService.ts +++ b/src/server/services/modelPricingService.ts @@ -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; } +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(); const routingReferenceCostCache = new Map(); @@ -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 { const models = new Map(); @@ -162,6 +206,17 @@ function normalizePricingModels(rawModels: unknown[]): Map 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 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, +): 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, ): 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 { @@ -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 { + 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; + } +} diff --git a/src/server/services/platforms/newApi.test.ts b/src/server/services/platforms/newApi.test.ts index a1616aa..de55a71 100644 --- a/src/server/services/platforms/newApi.test.ts +++ b/src/server/services/platforms/newApi.test.ts @@ -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 () => { diff --git a/src/server/services/proxyUsageParser.test.ts b/src/server/services/proxyUsageParser.test.ts index d116c4b..df17210 100644 --- a/src/server/services/proxyUsageParser.test.ts +++ b/src/server/services/proxyUsageParser.test.ts @@ -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, }); }); diff --git a/src/server/services/proxyUsageParser.ts b/src/server/services/proxyUsageParser.ts index e8938f6..c4213f2 100644 --- a/src/server/services/proxyUsageParser.ts +++ b/src/server/services/proxyUsageParser.ts @@ -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((sum, item) => sum + toPositiveInt(item), 0); } +function detectPromptTokensIncludeCache(record: Record): 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): 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): 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): ParsedProxyUsage { let promptTokens = firstPositiveInt(record, [ 'prompt_tokens', @@ -105,6 +189,9 @@ function parseUsageRecord(record: Record): 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): 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 } { diff --git a/src/web/i18n.supplement.ts b/src/web/i18n.supplement.ts index 8a75acc..283981b 100644 --- a/src/web/i18n.supplement.ts +++ b/src/web/i18n.supplement.ts @@ -74,7 +74,6 @@ export const zhToEnSupplemental: Record = { '站点未开启签到': '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', diff --git a/src/web/pages/Dashboard.tsx b/src/web/pages/Dashboard.tsx index e71300b..b080878 100644 --- a/src/web/pages/Dashboard.tsx +++ b/src/web/pages/Dashboard.tsx @@ -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(null); const [loading, setLoading] = useState(true); @@ -38,10 +44,17 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN const [siteTrend, setSiteTrend] = useState([]); const [siteLoading, setSiteLoading] = useState(true); const [sites, setSites] = useState([]); + const [siteSpeedStates, setSiteSpeedStates] = useState>({}); 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 `${ms}ms`; + : '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 ( + <> + + {ms}ms + + ); }; 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}: 测速失败`); } }} > - 测速 + {renderSiteSpeedLabel(site, idx)} 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([]); const [loading, setLoading] = useState(true); @@ -196,6 +277,8 @@ export default function ProxyLogs() { {paged.map(log => { const pathMeta = parseProxyLogPathMeta(log.errorMessage); + const billingDetailSummary = formatBillingDetailSummary(log); + const billingProcessLines = buildBillingProcessLines(log); return ( 日志详情 - - 请求模型: {log.modelRequested} - {log.modelActual && log.modelActual !== log.modelRequested && ( - <> → 实际模型: {log.modelActual} +
+
+ 请求模型: {log.modelRequested} + {log.modelActual && log.modelActual !== log.modelRequested && ( + <> → 实际模型: {log.modelActual} + )} + ,状态: {log.status === 'success' ? '成功' : '失败'} + ,用时 {formatLatency(log.latencyMs)} + ,站点: {log.siteName || '未知站点'} + ,账号: {log.username || '未知账号'} +
+ {billingDetailSummary && ( +
{billingDetailSummary}
)} - ,状态: {log.status === 'success' ? '成功' : '失败'} - ,用时 {formatLatency(log.latencyMs)} - ,站点: {log.siteName || '未知站点'} - ,账号: {log.username || '未知账号'} - +
+ {log.billingDetails && log.billingDetails.usage.cacheReadTokens > 0 && ( +
+ 缓存 Tokens + {log.billingDetails.usage.cacheReadTokens.toLocaleString()} +
+ )} + + {log.billingDetails && log.billingDetails.usage.cacheCreationTokens > 0 && ( +
+ 缓存创建 Tokens + {log.billingDetails.usage.cacheCreationTokens.toLocaleString()} +
+ )} + {/* 计费过程 section */}
计费过程 - - 输入 {log.promptTokens?.toLocaleString() || 0} tokens - {' + '}输出 {log.completionTokens?.toLocaleString() || 0} tokens - {' = '}总计 {log.totalTokens?.toLocaleString() || 0} tokens - {typeof log.estimatedCost === 'number' && ( - <>,预估费用 ${log.estimatedCost.toFixed(6)} - )} - + {billingProcessLines.length > 0 ? ( +
+ {billingProcessLines.map((line, index) => ( + {line} + ))} + 仅供参考,以实际扣费为准 +
+ ) : ( + + 输入 {log.promptTokens?.toLocaleString() || 0} tokens + {' + '}输出 {log.completionTokens?.toLocaleString() || 0} tokens + {' = '}总计 {log.totalTokens?.toLocaleString() || 0} tokens + {typeof log.estimatedCost === 'number' && ( + <>,预估费用 ${log.estimatedCost.toFixed(6)} + )} + + )}
{/* 下游请求路径 */} diff --git a/src/web/pages/accounts.rebind-panel-focus.test.tsx b/src/web/pages/accounts.rebind-panel-focus.test.tsx index 5835bcc..a14265c 100644 --- a/src/web/pages/accounts.rebind-panel-focus.test.tsx +++ b/src/web/pages/accounts.rebind-panel-focus.test.tsx @@ -48,6 +48,7 @@ describe('Accounts rebind panel focus', () => { balanceUsed: 0, todayReward: 0, todaySpend: 0, + accessToken: 'session-token', status: 'expired', checkinEnabled: true, siteId: 10, diff --git a/src/web/pages/dashboard.site-speed-button.test.tsx b/src/web/pages/dashboard.site-speed-button.test.tsx index c2ada02..9fe9a50 100644 --- a/src/web/pages/dashboard.site-speed-button.test.tsx +++ b/src/web/pages/dashboard.site-speed-button.test.tsx @@ -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 | null = null; + + try { + await act(async () => { + root = create( + + + , + ); + }); + 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(); + } + }); });