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

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

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

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

chore: add SQL migration for billing details in proxy_logs table

test: improve site speed button tests to ensure proper state updates without DOM lookups
This commit is contained in:
cita
2026-03-06 17:10:01 +08:00
parent 2a810facb8
commit 55b1ae564e
23 changed files with 1298 additions and 344 deletions
+249 -247
View File
@@ -26,12 +26,12 @@ import {
} from '../../services/accountHealthService.js';
import { appendSessionTokenRebindHint } from '../../services/alertRules.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
type AccountWithSiteRow = {
accounts: typeof schema.accounts.$inferSelect;
sites: typeof schema.sites.$inferSelect;
};
type AccountWithSiteRow = {
accounts: typeof schema.accounts.$inferSelect;
sites: typeof schema.sites.$inferSelect;
};
type AccountHealthRefreshResult = {
accountId: number;
username: string | null;
@@ -159,18 +159,18 @@ function normalizeLoginFailure(message: string | null | undefined): LoginFailure
message: raw || 'login failed',
};
}
function summarizeAccountHealthRefresh(results: AccountHealthRefreshResult[]) {
return {
total: results.length,
healthy: results.filter((item) => item.state === 'healthy').length,
unhealthy: results.filter((item) => item.state === 'unhealthy').length,
degraded: results.filter((item) => item.state === 'degraded').length,
disabled: results.filter((item) => item.state === 'disabled').length,
unknown: results.filter((item) => item.state === 'unknown').length,
success: results.filter((item) => item.status === 'success').length,
failed: results.filter((item) => item.status === 'failed').length,
skipped: results.filter((item) => item.status === 'skipped').length,
total: results.length,
healthy: results.filter((item) => item.state === 'healthy').length,
unhealthy: results.filter((item) => item.state === 'unhealthy').length,
degraded: results.filter((item) => item.state === 'degraded').length,
disabled: results.filter((item) => item.state === 'disabled').length,
unknown: results.filter((item) => item.state === 'unknown').length,
success: results.filter((item) => item.status === 'success').length,
failed: results.filter((item) => item.status === 'failed').length,
skipped: results.filter((item) => item.status === 'skipped').length,
};
}
@@ -235,18 +235,18 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
const capabilities = buildCapabilitiesForAccount(row.accounts);
if ((row.accounts.status || 'active') === 'disabled' || (row.sites.status || 'active') === 'disabled') {
setAccountRuntimeHealth(accountId, {
state: 'disabled',
reason: '账号或站点已禁用',
source: 'health-refresh',
});
return {
accountId,
username,
siteName,
status: 'skipped',
state: 'disabled',
message: '账号或站点已禁用',
setAccountRuntimeHealth(accountId, {
state: 'disabled',
reason: '账号或站点已禁用',
source: 'health-refresh',
});
return {
accountId,
username,
siteName,
status: 'skipped',
state: 'disabled',
message: '账号或站点已禁用',
};
}
@@ -285,46 +285,46 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
state: runtimeHealth.state,
message: runtimeHealth.reason,
};
} catch (error: any) {
const message = String(error?.message || '健康检查失败');
setAccountRuntimeHealth(accountId, {
state: 'unhealthy',
reason: message,
source: 'health-refresh',
});
return {
accountId,
username,
siteName,
status: 'failed',
state: 'unhealthy',
message,
};
}
}
async function executeRefreshAccountRuntimeHealth(accountId?: number) {
const rows = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.all();
const targetRows = Number.isFinite(accountId as number)
? rows.filter((row) => row.accounts.id === accountId)
: rows;
const results: AccountHealthRefreshResult[] = [];
for (const row of targetRows) {
results.push(await refreshRuntimeHealthForRow(row));
}
return {
summary: summarizeAccountHealthRefresh(results),
results,
};
}
export async function accountsRoutes(app: FastifyInstance) {
// List all accounts (with site info)
} catch (error: any) {
const message = String(error?.message || '健康检查失败');
setAccountRuntimeHealth(accountId, {
state: 'unhealthy',
reason: message,
source: 'health-refresh',
});
return {
accountId,
username,
siteName,
status: 'failed',
state: 'unhealthy',
message,
};
}
}
async function executeRefreshAccountRuntimeHealth(accountId?: number) {
const rows = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.all();
const targetRows = Number.isFinite(accountId as number)
? rows.filter((row) => row.accounts.id === accountId)
: rows;
const results: AccountHealthRefreshResult[] = [];
for (const row of targetRows) {
results.push(await refreshRuntimeHealthForRow(row));
}
return {
summary: summarizeAccountHealthRefresh(results),
results,
};
}
export async function accountsRoutes(app: FastifyInstance) {
// List all accounts (with site info)
app.get('/api/accounts', async () => {
const rows = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)).all();
@@ -398,19 +398,19 @@ export async function accountsRoutes(app: FastifyInstance) {
};
});
});
// Login to a site and auto-create account
app.post<{ Body: { siteId: number; username: string; password: string } }>('/api/accounts/login', async (request) => {
const { siteId, username, password } = request.body;
// Get site info
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
if (!site) return { success: false, message: 'site not found' };
// Get platform adapter
const adapter = getAdapter(site.platform);
if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
// Login to a site and auto-create account
app.post<{ Body: { siteId: number; username: string; password: string } }>('/api/accounts/login', async (request) => {
const { siteId, username, password } = request.body;
// Get site info
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
if (!site) return { success: false, message: 'site not found' };
// Get platform adapter
const adapter = getAdapter(site.platform);
if (!adapter) return { success: false, message: `不支持的平台: ${site.platform}` };
// Login to the target site
const loginResult = await adapter.login(site.url, username, password);
if (!loginResult.success || !loginResult.accessToken) {
@@ -421,24 +421,24 @@ export async function accountsRoutes(app: FastifyInstance) {
message: normalizedFailure.message,
};
}
const guessedPlatformUserId = guessPlatformUserIdFromUsername(username);
// Auto-fetch API token(s)
let apiToken: string | null = null;
let apiTokens: Array<{ name?: string | null; key?: string | null; enabled?: boolean | null }> = [];
try {
apiToken = await adapter.getApiToken(site.url, loginResult.accessToken, guessedPlatformUserId);
} catch { }
try {
apiTokens = await adapter.getApiTokens(site.url, loginResult.accessToken, guessedPlatformUserId);
} catch { }
const preferredApiToken = apiTokens.find((token) => token.enabled !== false && token.key)?.key || apiToken || null;
const existing = await db.select().from(schema.accounts)
.where(and(eq(schema.accounts.siteId, siteId), eq(schema.accounts.username, username)))
.get();
const guessedPlatformUserId = guessPlatformUserIdFromUsername(username);
// Auto-fetch API token(s)
let apiToken: string | null = null;
let apiTokens: Array<{ name?: string | null; key?: string | null; enabled?: boolean | null }> = [];
try {
apiToken = await adapter.getApiToken(site.url, loginResult.accessToken, guessedPlatformUserId);
} catch { }
try {
apiTokens = await adapter.getApiTokens(site.url, loginResult.accessToken, guessedPlatformUserId);
} catch { }
const preferredApiToken = apiTokens.find((token) => token.enabled !== false && token.key)?.key || apiToken || null;
const existing = await db.select().from(schema.accounts)
.where(and(eq(schema.accounts.siteId, siteId), eq(schema.accounts.username, username)))
.get();
const extraConfigPatch: Record<string, unknown> = {
credentialMode: 'session',
autoRelogin: {
@@ -446,24 +446,24 @@ export async function accountsRoutes(app: FastifyInstance) {
passwordCipher: encryptAccountPassword(password),
updatedAt: new Date().toISOString(),
},
};
if (guessedPlatformUserId) {
extraConfigPatch.platformUserId = guessedPlatformUserId;
}
const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, extraConfigPatch);
// Create or update account
let accountId = existing?.id;
if (existing) {
await db.update(schema.accounts).set({
accessToken: loginResult.accessToken,
apiToken: preferredApiToken || undefined,
checkinEnabled: true,
status: 'active',
extraConfig,
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, existing.id)).run();
} else {
};
if (guessedPlatformUserId) {
extraConfigPatch.platformUserId = guessedPlatformUserId;
}
const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, extraConfigPatch);
// Create or update account
let accountId = existing?.id;
if (existing) {
await db.update(schema.accounts).set({
accessToken: loginResult.accessToken,
apiToken: preferredApiToken || undefined,
checkinEnabled: true,
status: 'active',
extraConfig,
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, existing.id)).run();
} else {
const inserted = await db.insert(schema.accounts).values({
siteId,
username,
@@ -477,39 +477,39 @@ export async function accountsRoutes(app: FastifyInstance) {
const insertedId = Number(inserted.lastInsertRowid || 0);
accountId = insertedId > 0 ? insertedId : undefined;
}
const result = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId!)).get();
if (!result) {
return { success: false, message: 'account create failed' };
}
if (apiTokens.length > 0) {
try {
const result = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId!)).get();
if (!result) {
return { success: false, message: 'account create failed' };
}
if (apiTokens.length > 0) {
try {
await syncTokensFromUpstream(result.id, apiTokens);
} catch { }
} else if (preferredApiToken) {
try {
} catch { }
} else if (preferredApiToken) {
try {
await ensureDefaultTokenForAccount(result.id, preferredApiToken, { name: 'default', source: 'sync' });
} catch { }
}
// Auto-refresh balance
try { await refreshBalance(result.id); } catch { }
try {
await refreshModelsForAccount(result.id);
} catch { }
}
// Auto-refresh balance
try { await refreshBalance(result.id); } catch { }
try {
await refreshModelsForAccount(result.id);
await rebuildTokenRoutesFromAvailability();
} catch { }
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
return {
success: true,
account,
apiTokenFound: !!preferredApiToken,
tokenCount: apiTokens.length,
reusedAccount: !!existing,
};
});
} catch { }
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
return {
success: true,
account,
apiTokenFound: !!preferredApiToken,
tokenCount: apiTokens.length,
reusedAccount: !!existing,
};
});
// Verify credentials against a site.
app.post<{ Body: { siteId: number; accessToken: string; platformUserId?: number; credentialMode?: AccountCredentialMode } }>('/api/accounts/verify-token', async (request) => {
const { siteId, platformUserId } = request.body;
@@ -523,10 +523,13 @@ export async function accountsRoutes(app: FastifyInstance) {
}
const adapter = getAdapter(site.platform);
if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
if (!adapter) return { success: false, message: `不支持的平台: ${site.platform}` };
const normalizedPlatform = String(adapter.platformName || site.platform || '').trim().toLowerCase();
const hasProvidedUserId = Number.isFinite(platformUserId) && platformUserId > 0;
const parsedPlatformUserId = typeof platformUserId === 'number' && Number.isFinite(platformUserId) && platformUserId > 0
? Math.trunc(platformUserId)
: undefined;
const hasProvidedUserId = parsedPlatformUserId !== undefined;
const skipRawShieldDetection = normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter';
const diagnoseVerificationFailure = async (): Promise<VerifyFailureReason> => {
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
@@ -563,7 +566,7 @@ export async function accountsRoutes(app: FastifyInstance) {
candidates.add(`token=${raw}`);
}
const diagnosticUserId = hasProvidedUserId ? String(platformUserId) : '0';
const diagnosticUserId = hasProvidedUserId ? String(parsedPlatformUserId) : '0';
const headerVariants: Record<string, string>[] = [
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': diagnosticUserId },
];
@@ -624,7 +627,7 @@ export async function accountsRoutes(app: FastifyInstance) {
return null;
};
if (!platformUserId && (normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter')) {
if (!hasProvidedUserId && (normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter')) {
const preflightReason = await diagnoseVerificationFailure();
if (preflightReason === 'needs-user-id') {
return buildVerificationFailureResponse(preflightReason);
@@ -634,7 +637,7 @@ export async function accountsRoutes(app: FastifyInstance) {
if (credentialMode === 'apikey') {
try {
const models = await withTimeout(
() => adapter.getModels(site.url, accessToken, platformUserId),
() => adapter.getModels(site.url, accessToken, parsedPlatformUserId),
ACCOUNT_VERIFY_TIMEOUT_MS,
`Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`,
);
@@ -666,7 +669,7 @@ export async function accountsRoutes(app: FastifyInstance) {
let result: any;
try {
result = await withTimeout(
() => adapter.verifyToken(site.url, accessToken, platformUserId),
() => adapter.verifyToken(site.url, accessToken, parsedPlatformUserId),
ACCOUNT_VERIFY_TIMEOUT_MS,
`Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`,
);
@@ -742,7 +745,7 @@ export async function accountsRoutes(app: FastifyInstance) {
candidates.add(`token=${raw}`);
}
const diagnosticUserId = hasProvidedUserId ? String(platformUserId) : '0';
const diagnosticUserId = hasProvidedUserId ? String(parsedPlatformUserId) : '0';
const headerVariants: Record<string, string>[] = [
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': diagnosticUserId },
];
@@ -1094,7 +1097,7 @@ export async function accountsRoutes(app: FastifyInstance) {
usernameDetected: !!(!body.username && username),
};
});
// Update an account
app.put<{ Params: { id: string }; Body: any }>('/api/accounts/:id', async (request, reply) => {
const id = parseInt(request.params.id);
@@ -1157,98 +1160,97 @@ export async function accountsRoutes(app: FastifyInstance) {
updates.updatedAt = new Date().toISOString();
await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
if (typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
try {
if (typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
try {
await ensureDefaultTokenForAccount(id, updates.apiToken, { name: 'default', source: 'manual' });
} catch { }
}
try {
await refreshModelsForAccount(id);
} catch { }
}
try {
await refreshModelsForAccount(id);
await rebuildTokenRoutesFromAvailability();
} catch { }
return await db.select().from(schema.accounts).where(eq(schema.accounts.id, id)).get();
});
// Delete an account
app.delete<{ Params: { id: string } }>('/api/accounts/:id', async (request) => {
const id = parseInt(request.params.id);
await db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run();
try {
} catch { }
return await db.select().from(schema.accounts).where(eq(schema.accounts.id, id)).get();
});
// Delete an account
app.delete<{ Params: { id: string } }>('/api/accounts/:id', async (request) => {
const id = parseInt(request.params.id);
await db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run();
try {
await rebuildTokenRoutesFromAvailability();
} catch { }
return { success: true };
});
app.post<{ Body?: { accountId?: number; wait?: boolean } }>('/api/accounts/health/refresh', async (request, reply) => {
const rawAccountId = request.body?.accountId as unknown;
const hasAccountId = rawAccountId !== undefined && rawAccountId !== null && String(rawAccountId).trim() !== '';
const accountId = hasAccountId ? Number.parseInt(String(rawAccountId), 10) : undefined;
const wait = request.body?.wait === true;
if (hasAccountId && (!Number.isFinite(accountId) || (accountId as number) <= 0)) {
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
}
if (wait) {
const result = await executeRefreshAccountRuntimeHealth(accountId);
if (accountId && result.summary.total === 0) {
return reply.code(404).send({ success: false, message: '账号不存在' });
}
return {
success: true,
...result,
};
}
const taskTitle = accountId ? `刷新账号运行健康状态 #${accountId}` : '刷新全部账号运行健康状态';
const dedupeKey = accountId ? `refresh-account-runtime-health-${accountId}` : 'refresh-all-account-runtime-health';
const { task, reused } = startBackgroundTask(
{
type: 'status',
title: taskTitle,
dedupeKey,
notifyOnFailure: true,
successMessage: (currentTask) => {
const summary = (currentTask.result as { summary?: ReturnType<typeof summarizeAccountHealthRefresh> })?.summary;
if (!summary) return `${taskTitle}已完成`;
return `${taskTitle}完成:健康 ${summary.healthy},异常 ${summary.unhealthy},禁用 ${summary.disabled}`;
},
failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`,
},
async () => executeRefreshAccountRuntimeHealth(accountId),
);
return reply.code(202).send({
success: true,
queued: true,
reused,
jobId: task.id,
status: task.status,
message: reused
? '账号运行健康状态刷新进行中,请稍后查看账号列表'
: '已开始刷新账号运行健康状态,请稍后查看账号列表',
});
});
// Refresh balance for an account
app.post<{ Params: { id: string } }>('/api/accounts/:id/balance', async (request, reply) => {
const id = parseInt(request.params.id);
try {
const result = await refreshBalance(id);
if (!result) {
reply.code(404);
return { message: 'account not found or platform not supported' };
}
return result;
} catch (err: any) {
reply.code(400);
return { message: err?.message || 'failed to fetch balance' };
}
});
}
} catch { }
return { success: true };
});
app.post<{ Body?: { accountId?: number; wait?: boolean } }>('/api/accounts/health/refresh', async (request, reply) => {
const rawAccountId = request.body?.accountId as unknown;
const hasAccountId = rawAccountId !== undefined && rawAccountId !== null && String(rawAccountId).trim() !== '';
const accountId = hasAccountId ? Number.parseInt(String(rawAccountId), 10) : undefined;
const wait = request.body?.wait === true;
if (hasAccountId && (!Number.isFinite(accountId) || (accountId as number) <= 0)) {
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
}
if (wait) {
const result = await executeRefreshAccountRuntimeHealth(accountId);
if (accountId && result.summary.total === 0) {
return reply.code(404).send({ success: false, message: '账号不存在' });
}
return {
success: true,
...result,
};
}
const taskTitle = accountId ? `刷新账号运行健康状态 #${accountId}` : '刷新全部账号运行健康状态';
const dedupeKey = accountId ? `refresh-account-runtime-health-${accountId}` : 'refresh-all-account-runtime-health';
const { task, reused } = startBackgroundTask(
{
type: 'status',
title: taskTitle,
dedupeKey,
notifyOnFailure: true,
successMessage: (currentTask) => {
const summary = (currentTask.result as { summary?: ReturnType<typeof summarizeAccountHealthRefresh> })?.summary;
if (!summary) return `${taskTitle}已完成`;
return `${taskTitle}完成:健康 ${summary.healthy},异常 ${summary.unhealthy},禁用 ${summary.disabled}`;
},
failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`,
},
async () => executeRefreshAccountRuntimeHealth(accountId),
);
return reply.code(202).send({
success: true,
queued: true,
reused,
jobId: task.id,
status: task.status,
message: reused
? '账号运行健康状态刷新进行中,请稍后查看账号列表'
: '已开始刷新账号运行健康状态,请稍后查看账号列表',
});
});
// Refresh balance for an account
app.post<{ Params: { id: string } }>('/api/accounts/:id/balance', async (request, reply) => {
const id = parseInt(request.params.id);
try {
const result = await refreshBalance(id);
if (!result) {
reply.code(404);
return { message: 'account not found or platform not supported' };
}
return result;
} catch (err: any) {
reply.code(400);
return { message: err?.message || 'failed to fetch balance' };
}
});
}
+11
View File
@@ -25,6 +25,16 @@ function parseBooleanFlag(raw?: string): boolean {
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
function parseProxyLogBillingDetails(value: unknown): Record<string, unknown> | null {
if (typeof value !== 'string' || value.trim().length === 0) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null;
} catch {
return null;
}
}
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
@@ -187,6 +197,7 @@ export async function statsRoutes(app: FastifyInstance) {
return rows.map((row) => ({
...row.proxy_logs,
billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails),
username: row.accounts?.username || null,
siteName: row.sites?.name || null,
siteUrl: row.sites?.url || null,
@@ -10,6 +10,7 @@ const refreshModelsAndRebuildRoutesMock = vi.fn();
const reportProxyAllFailedMock = vi.fn();
const reportTokenExpiredMock = vi.fn();
const estimateProxyCostMock = vi.fn(async (_arg?: any) => 0);
const buildProxyBillingDetailsMock = vi.fn(async (_arg?: any) => null);
const fetchModelPricingCatalogMock = vi.fn(async (_arg?: any): Promise<any> => null);
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({
...usage,
@@ -50,6 +51,7 @@ vi.mock('../../services/alertRules.js', () => ({
vi.mock('../../services/modelPricingService.js', () => ({
estimateProxyCost: (arg: any) => estimateProxyCostMock(arg),
buildProxyBillingDetails: (arg: any) => buildProxyBillingDetailsMock(arg),
fetchModelPricingCatalog: (arg: any) => fetchModelPricingCatalogMock(arg),
}));
@@ -92,6 +94,7 @@ describe('chat proxy stream behavior', () => {
reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset();
estimateProxyCostMock.mockClear();
buildProxyBillingDetailsMock.mockClear();
fetchModelPricingCatalogMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
dbInsertMock.mockClear();
+59 -2
View File
@@ -5,7 +5,7 @@ import { fetch } from 'undici';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
import { isTokenExpiredError } from '../../services/alertRules.js';
import { estimateProxyCost } from '../../services/modelPricingService.js';
import { buildProxyBillingDetails, estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
@@ -347,7 +347,14 @@ async function handleChatProxyRequest(
const streamContext = createStreamTransformContext(modelName);
const claudeContext = createClaudeDownstreamContext();
let parsedUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
let parsedUsage: ReturnType<typeof parseProxyUsage> = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
};
const writeLines = (lines: string[]) => {
for (const line of lines) {
@@ -429,10 +436,25 @@ async function handleChatProxyRequest(
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
let billingDetails = await buildProxyBillingDetails({
site: selected.site,
account: selected.account,
modelName,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
billingDetails = null;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
@@ -450,6 +472,7 @@ async function handleChatProxyRequest(
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
billingDetails,
successfulUpstreamPath,
);
return;
@@ -593,10 +616,25 @@ async function handleChatProxyRequest(
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
let billingDetails = await buildProxyBillingDetails({
site: selected.site,
account: selected.account,
modelName,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
billingDetails = null;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
@@ -614,6 +652,7 @@ async function handleChatProxyRequest(
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
billingDetails,
successfulUpstreamPath,
);
return;
@@ -655,10 +694,25 @@ async function handleChatProxyRequest(
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
let billingDetails = await buildProxyBillingDetails({
site: selected.site,
account: selected.account,
modelName,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
billingDetails = null;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
@@ -676,6 +730,7 @@ async function handleChatProxyRequest(
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
billingDetails,
successfulUpstreamPath,
);
@@ -717,6 +772,7 @@ async function logProxy(
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
billingDetails: unknown = null,
upstreamPath: string | null = null,
) {
try {
@@ -739,6 +795,7 @@ async function logProxy(
completionTokens,
totalTokens,
estimatedCost,
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
+48 -9
View File
@@ -5,7 +5,7 @@ import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
import { isTokenExpiredError } from '../../services/alertRules.js';
import { estimateProxyCost } from '../../services/modelPricingService.js';
import { buildProxyBillingDetails, estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js';
@@ -106,7 +106,14 @@ export async function completionsProxyRoute(app: FastifyInstance) {
}
const decoder = new TextDecoder();
let parsedUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
let parsedUsage: ReturnType<typeof parseProxyUsage> = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
};
let sseBuffer = '';
try {
while (true) {
@@ -160,15 +167,30 @@ export async function completionsProxyRoute(app: FastifyInstance) {
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
let billingDetails = await buildProxyBillingDetails({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
billingDetails = null;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
);
return;
}
@@ -198,18 +220,33 @@ export async function completionsProxyRoute(app: FastifyInstance) {
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
let billingDetails = await buildProxyBillingDetails({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
billingDetails = null;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
);
return reply.send(data);
return reply.send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount);
@@ -237,10 +274,11 @@ async function logProxy(
latencyMs: number,
errorMessage: string | null,
retryCount: number,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
billingDetails: unknown = null,
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
@@ -261,6 +299,7 @@ async function logProxy(
completionTokens,
totalTokens,
estimatedCost,
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
+24 -7
View File
@@ -5,7 +5,7 @@ import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
import { isTokenExpiredError } from '../../services/alertRules.js';
import { estimateProxyCost } from '../../services/modelPricingService.js';
import { buildProxyBillingDetails, estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
@@ -115,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,
+44 -9
View File
@@ -5,7 +5,7 @@ import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
import { isTokenExpiredError } from '../../services/alertRules.js';
import { estimateProxyCost } from '../../services/modelPricingService.js';
import { buildProxyBillingDetails, estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
@@ -571,11 +571,7 @@ function convertResponsesBodyToOpenAiBody(
return payload;
}
type UsageSummary = {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
type UsageSummary = ReturnType<typeof parseProxyUsage>;
type ResponsesToolCall = {
id: string;
@@ -1358,7 +1354,14 @@ export async function responsesProxyRoute(app: FastifyInstance) {
}
const decoder = new TextDecoder();
let parsedUsage: UsageSummary = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
let parsedUsage: UsageSummary = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
};
let sseBuffer = '';
const passthroughResponsesStream = successfulUpstreamPath === '/v1/responses';
@@ -1479,15 +1482,30 @@ export async function responsesProxyRoute(app: FastifyInstance) {
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
let billingDetails = await buildProxyBillingDetails({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
billingDetails = null;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
successfulUpstreamPath,
);
return;
@@ -1530,16 +1548,31 @@ export async function responsesProxyRoute(app: FastifyInstance) {
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
let billingDetails = await buildProxyBillingDetails({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
billingDetails = null;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
successfulUpstreamPath,
);
return reply.send(downstreamData);
@@ -1575,6 +1608,7 @@ async function logProxy(
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
billingDetails: unknown = null,
upstreamPath: string | null = null,
) {
try {
@@ -1597,6 +1631,7 @@ async function logProxy(
completionTokens,
totalTokens,
estimatedCost,
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,