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