chore: initialize clean metapi repository

This commit is contained in:
cita
2026-02-28 00:36:28 +08:00
commit 2ecc23730d
199 changed files with 46078 additions and 0 deletions
@@ -0,0 +1,378 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, expect, it, beforeAll, beforeEach, afterAll, vi } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
import { and, eq, sql } from 'drizzle-orm';
const getApiTokensMock = vi.fn();
const getApiTokenMock = vi.fn();
const createApiTokenMock = vi.fn();
const getUserGroupsMock = vi.fn();
const deleteApiTokenMock = vi.fn();
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
getApiToken: (...args: unknown[]) => getApiTokenMock(...args),
createApiToken: (...args: unknown[]) => createApiTokenMock(...args),
getUserGroups: (...args: unknown[]) => getUserGroupsMock(...args),
deleteApiToken: (...args: unknown[]) => deleteApiTokenMock(...args),
}),
}));
type DbModule = typeof import('../../db/index.js');
describe('account tokens sync routes with site status', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
let seedId = 0;
const nextSeed = () => {
seedId += 1;
return seedId;
};
const seedAccount = (input: { siteStatus?: 'active' | 'disabled'; accountStatus?: string; accessToken?: string | null }) => {
const id = nextSeed();
const site = db.insert(schema.sites).values({
name: `site-${id}`,
url: `https://site-${id}.example.com`,
platform: 'new-api',
}).returning().get();
if (input.siteStatus === 'disabled') {
db.run(sql`update sites set status = 'disabled' where id = ${site.id}`);
}
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: `user-${id}`,
accessToken: input.accessToken ?? `access-token-${id}`,
status: input.accountStatus ?? 'active',
}).returning().get();
return { site, account };
};
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-tokens-sync-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accountTokens.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.accountTokensRoutes);
});
beforeEach(() => {
getApiTokensMock.mockReset();
getApiTokenMock.mockReset();
createApiTokenMock.mockReset();
getUserGroupsMock.mockReset();
deleteApiTokenMock.mockReset();
seedId = 0;
db.delete(schema.accountTokens).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('returns skipped for single-account sync when site is disabled', async () => {
const { account } = seedAccount({ siteStatus: 'disabled' });
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: false,
status: 'skipped',
reason: 'site_disabled',
});
expect(getApiTokensMock).not.toHaveBeenCalled();
expect(getApiTokenMock).not.toHaveBeenCalled();
});
it('returns skipped when upstream has no api tokens', async () => {
const { account } = seedAccount({ siteStatus: 'active' });
getApiTokensMock.mockResolvedValue([]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: false,
status: 'skipped',
reason: 'no_upstream_tokens',
});
const tokenRows = db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows.length).toBe(0);
});
it('sync-all skips disabled-site accounts and syncs active-site accounts', async () => {
const disabled = seedAccount({ siteStatus: 'disabled' });
const active = seedAccount({ siteStatus: 'active' });
getApiTokensMock.mockResolvedValue([
{ name: 'default', key: 'sk-synced-token', enabled: true },
]);
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens/sync-all',
payload: { wait: true },
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
success: boolean;
summary: {
total: number;
synced: number;
skipped: number;
failed: number;
};
results: Array<{ accountId: number; status: string; reason?: string; synced?: boolean }>;
};
expect(body.success).toBe(true);
expect(body.summary).toMatchObject({
total: 2,
synced: 1,
skipped: 1,
failed: 0,
});
const skipped = body.results.find((item) => item.accountId === disabled.account.id);
const synced = body.results.find((item) => item.accountId === active.account.id);
expect(skipped).toMatchObject({
accountId: disabled.account.id,
status: 'skipped',
reason: 'site_disabled',
});
expect(synced).toMatchObject({
accountId: active.account.id,
status: 'synced',
synced: true,
});
const syncedDefaultToken = db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, active.account.id), eq(schema.accountTokens.isDefault, true)))
.get();
expect(syncedDefaultToken?.token).toBe('sk-synced-token');
});
it('creates token via upstream api and syncs into local store when manual token is omitted', async () => {
const { account, site } = seedAccount({ siteStatus: 'active' });
createApiTokenMock.mockResolvedValue(true);
getApiTokensMock.mockResolvedValue([
{ name: 'created-from-upstream', key: 'sk-created-upstream-token', enabled: true },
]);
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens',
payload: {
accountId: account.id,
name: 'created-from-upstream',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
createdViaUpstream: true,
synced: true,
status: 'synced',
});
expect(createApiTokenMock).toHaveBeenCalledTimes(1);
expect(createApiTokenMock.mock.calls[0][0]).toBe(site.url);
expect(createApiTokenMock.mock.calls[0][1]).toBe(account.accessToken);
const tokenRows = db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows.length).toBe(1);
expect(tokenRows[0].name).toBe('created-from-upstream');
expect(tokenRows[0].token).toBe('sk-created-upstream-token');
expect(tokenRows[0].source).toBe('sync');
});
it('passes token creation options to upstream adapter', async () => {
const { account } = seedAccount({ siteStatus: 'active' });
createApiTokenMock.mockResolvedValue(true);
getApiTokensMock.mockResolvedValue([
{ name: 'custom-token', key: 'sk-created-upstream-token', enabled: true },
]);
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens',
payload: {
accountId: account.id,
name: 'custom-token',
group: 'vip',
unlimitedQuota: false,
remainQuota: 123456,
expiredTime: 2_000_000_000,
allowIps: '1.1.1.1,2.2.2.2',
},
});
expect(response.statusCode).toBe(200);
expect(createApiTokenMock).toHaveBeenCalledTimes(1);
expect(createApiTokenMock.mock.calls[0][3]).toMatchObject({
name: 'custom-token',
group: 'vip',
unlimitedQuota: false,
remainQuota: 123456,
expiredTime: 2_000_000_000,
allowIps: '1.1.1.1,2.2.2.2',
});
});
it('returns 400 when limited token misses remainQuota', async () => {
const { account } = seedAccount({ siteStatus: 'active' });
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens',
payload: {
accountId: account.id,
name: 'bad-token',
unlimitedQuota: false,
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
success: false,
message: '有限额度令牌必须填写 remainQuota',
});
expect(createApiTokenMock).not.toHaveBeenCalled();
});
it('returns 502 when upstream token creation fails', async () => {
const { account } = seedAccount({ siteStatus: 'active' });
createApiTokenMock.mockResolvedValue(false);
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens',
payload: {
accountId: account.id,
name: 'created-from-upstream',
},
});
expect(response.statusCode).toBe(502);
expect(response.json()).toMatchObject({
success: false,
message: '站点创建令牌失败',
});
});
it('fetches account token groups from upstream', async () => {
const { account } = seedAccount({ siteStatus: 'active' });
getUserGroupsMock.mockResolvedValue(['default', 'vip']);
const response = await app.inject({
method: 'GET',
url: `/api/account-tokens/groups/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
groups: ['default', 'vip'],
});
expect(getUserGroupsMock).toHaveBeenCalledTimes(1);
});
it('deletes upstream token before removing local token', async () => {
const { account, site } = seedAccount({ siteStatus: 'active' });
const token = db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'upstream-token',
token: 'sk-upstream-token',
source: 'sync',
enabled: true,
isDefault: false,
}).returning().get();
deleteApiTokenMock.mockResolvedValue(true);
const response = await app.inject({
method: 'DELETE',
url: `/api/account-tokens/${token.id}`,
});
expect(response.statusCode).toBe(200);
expect(deleteApiTokenMock).toHaveBeenCalledTimes(1);
expect(deleteApiTokenMock.mock.calls[0][0]).toBe(site.url);
expect(deleteApiTokenMock.mock.calls[0][1]).toBe(account.accessToken);
expect(deleteApiTokenMock.mock.calls[0][2]).toBe('sk-upstream-token');
const removed = db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
expect(removed).toBeUndefined();
});
it('keeps local token when upstream deletion fails', async () => {
const { account } = seedAccount({ siteStatus: 'active' });
const token = db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'upstream-token',
token: 'sk-upstream-token',
source: 'sync',
enabled: true,
isDefault: false,
}).returning().get();
deleteApiTokenMock.mockResolvedValue(false);
const response = await app.inject({
method: 'DELETE',
url: `/api/account-tokens/${token.id}`,
});
expect(response.statusCode).toBe(502);
expect(response.json()).toMatchObject({
success: false,
message: '站点删除令牌失败,本地未删除',
});
const existing = db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
expect(existing).toBeDefined();
});
});
+726
View File
@@ -0,0 +1,726 @@
import { FastifyInstance } from 'fastify';
import { and, eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import {
ensureDefaultTokenForAccount,
listTokensWithRelations,
normalizeTokenForDisplay,
maskToken,
repairDefaultToken,
setDefaultToken,
syncTokensFromUpstream,
} from '../../services/accountTokenService.js';
import { getAdapter } from '../../services/platforms/index.js';
import { resolvePlatformUserId } from '../../services/accountExtraConfig.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
type AccountWithSiteRow = {
accounts: typeof schema.accounts.$inferSelect;
sites: typeof schema.sites.$inferSelect;
};
type SyncExecutionResult = {
accountId: number;
accountName: string;
accountStatus: string | null;
siteId: number;
siteName: string;
siteStatus: string | null;
status: 'synced' | 'skipped' | 'failed';
reason?: string;
message?: string;
synced: boolean;
created: number;
updated: number;
total: number;
defaultTokenId?: number | null;
};
const TOKEN_SYNC_TIMEOUT_MS = 15_000;
const SYNC_ALL_BATCH_SIZE = 3;
function buildSyncAccountLabel(item: SyncExecutionResult): string {
const account = (item.accountName || `#${item.accountId}`).trim();
const site = (item.siteName || 'unknown-site').trim();
return `${account} @ ${site}`;
}
function buildSyncReason(item: SyncExecutionResult): string {
const message = String(item.message || item.reason || '').trim();
if (!message) return '';
if (message.length <= 32) return message;
return `${message.slice(0, 32)}...`;
}
function buildTokenSyncTaskDetailMessage(results: SyncExecutionResult[]): string {
if (!Array.isArray(results) || results.length === 0) return '';
const synced = results.filter((item) => item.status === 'synced');
const skipped = results.filter((item) => item.status === 'skipped');
const failed = results.filter((item) => item.status === 'failed');
const renderRows = (rows: SyncExecutionResult[], withReason = false) => {
const sliced = rows.slice(0, 12).map((item) => {
const base = buildSyncAccountLabel(item);
if (!withReason) return base;
const reason = buildSyncReason(item);
return reason ? `${base}(${reason})` : base;
});
if (rows.length > 12) sliced.push(`...等${rows.length}个`);
return sliced.join('、');
};
const segments: string[] = [
`成功(${synced.length}): ${synced.length > 0 ? renderRows(synced) : '-'}`,
`跳过(${skipped.length}): ${skipped.length > 0 ? renderRows(skipped, true) : '-'}`,
`失败(${failed.length}): ${failed.length > 0 ? renderRows(failed, true) : '-'}`,
];
return segments.join('\n');
}
function isSiteDisabled(status?: string | null): boolean {
return (status || 'active') === 'disabled';
}
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function parseOptionalBoolean(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value;
if (typeof value !== 'string') return undefined;
const normalized = value.trim().toLowerCase();
if (normalized === 'true' || normalized === '1') return true;
if (normalized === 'false' || normalized === '0') return false;
return undefined;
}
function parsePositiveInteger(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
const normalized = Math.trunc(value);
return normalized > 0 ? normalized : undefined;
}
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
const normalized = Number.parseInt(trimmed, 10);
if (Number.isNaN(normalized) || normalized <= 0) return undefined;
return normalized;
}
function parseExpiredTime(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
const normalized = Math.trunc(value);
return normalized > 0 ? normalized : undefined;
}
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (/^\d+$/.test(trimmed)) {
const numericValue = Number.parseInt(trimmed, 10);
if (!Number.isNaN(numericValue) && numericValue > 0) return numericValue;
}
const parsedMs = Date.parse(trimmed);
if (!Number.isFinite(parsedMs)) return undefined;
const seconds = Math.trunc(parsedMs / 1000);
return seconds > 0 ? seconds : undefined;
}
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
fn(),
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExecutionResult> {
const accountId = row.accounts.id;
const base = {
accountId,
accountName: row.accounts.username || `account-${accountId}`,
accountStatus: row.accounts.status,
siteId: row.sites.id,
siteName: row.sites.name,
siteStatus: row.sites.status,
};
if (isSiteDisabled(row.sites.status)) {
return {
...base,
status: 'skipped',
reason: 'site_disabled',
message: 'site disabled',
synced: false,
created: 0,
updated: 0,
total: 0,
defaultTokenId: null,
};
}
if (!row.accounts.accessToken) {
if (row.accounts.apiToken) {
ensureDefaultTokenForAccount(accountId, row.accounts.apiToken, { name: 'default', source: 'legacy' });
}
return {
...base,
status: 'skipped',
reason: 'missing_access_token',
synced: false,
created: 0,
updated: 0,
total: 0,
defaultTokenId: null,
};
}
const adapter = getAdapter(row.sites.platform);
if (!adapter) {
return {
...base,
status: 'failed',
reason: 'unsupported_platform',
message: `不支持的平台: ${row.sites.platform}`,
synced: false,
created: 0,
updated: 0,
total: 0,
defaultTokenId: null,
};
}
try {
const platformUserId = resolvePlatformUserId(row.accounts.extraConfig, row.accounts.username);
let tokens = await withTimeout(
() => adapter.getApiTokens(row.sites.url, row.accounts.accessToken, platformUserId),
TOKEN_SYNC_TIMEOUT_MS,
`token sync timeout (${Math.round(TOKEN_SYNC_TIMEOUT_MS / 1000)}s)`,
);
if (tokens.length === 0) {
const fallback = await withTimeout(
() => adapter.getApiToken(row.sites.url, row.accounts.accessToken, platformUserId),
TOKEN_SYNC_TIMEOUT_MS,
`token sync timeout (${Math.round(TOKEN_SYNC_TIMEOUT_MS / 1000)}s)`,
);
if (fallback) {
tokens = [{ name: 'default', key: fallback, enabled: true }];
}
}
if (tokens.length === 0) {
return {
...base,
status: 'skipped',
reason: 'no_upstream_tokens',
message: 'upstream returned no api tokens',
synced: false,
created: 0,
updated: 0,
total: 0,
defaultTokenId: null,
};
}
const synced = syncTokensFromUpstream(accountId, tokens);
return {
...base,
status: 'synced',
synced: true,
...synced,
};
} catch (error: any) {
return {
...base,
status: 'failed',
reason: 'sync_error',
message: error?.message || 'sync failed',
synced: false,
created: 0,
updated: 0,
total: 0,
defaultTokenId: null,
};
}
}
function appendTokenSyncEvent(result: SyncExecutionResult) {
const title = result.status === 'synced'
? '令牌同步成功'
: (result.status === 'skipped' ? '令牌同步跳过' : '令牌同步失败');
const level = result.status === 'synced'
? 'info'
: (result.status === 'skipped' ? 'warning' : 'error');
const detail = result.status === 'synced'
? `新增 ${result.created},更新 ${result.updated},总数 ${result.total}`
: (result.message || result.reason || 'sync skipped');
try {
db.insert(schema.events).values({
type: 'token',
title,
message: `${result.accountName} @ ${result.siteName}: ${detail}`,
level,
relatedId: result.accountId,
relatedType: 'account',
}).run();
} catch {}
}
async function executeSyncAllAccountTokens() {
const rows = db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.status, 'active'))
.all();
const results: SyncExecutionResult[] = [];
for (let offset = 0; offset < rows.length; offset += SYNC_ALL_BATCH_SIZE) {
const batch = rows.slice(offset, offset + SYNC_ALL_BATCH_SIZE);
const batchResults = await Promise.all(
batch.map(async (row) => {
const result = await executeAccountTokenSync(row);
appendTokenSyncEvent(result);
return result;
}),
);
results.push(...batchResults);
}
const summary = {
total: results.length,
synced: results.filter((item) => item.status === 'synced').length,
skipped: results.filter((item) => item.status === 'skipped').length,
failed: results.filter((item) => item.status === 'failed').length,
created: results.reduce((acc, item) => acc + item.created, 0),
updated: results.reduce((acc, item) => acc + item.updated, 0),
};
return { summary, results };
}
export async function accountTokensRoutes(app: FastifyInstance) {
app.get<{ Querystring: { accountId?: string } }>('/api/account-tokens', async (request) => {
const accountId = request.query.accountId ? Number.parseInt(request.query.accountId, 10) : undefined;
return listTokensWithRelations(Number.isFinite(accountId as number) ? accountId : undefined);
});
app.post<{ Body: {
accountId: number;
name?: string;
token?: string;
enabled?: boolean;
isDefault?: boolean;
source?: string;
group?: string;
unlimitedQuota?: boolean | string;
remainQuota?: number | string;
expiredTime?: number | string;
allowIps?: string;
modelLimitsEnabled?: boolean | string;
modelLimits?: string;
} }>('/api/account-tokens', async (request, reply) => {
const body = request.body;
const row = db.select()
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, body.accountId))
.get();
if (!row) {
return reply.code(404).send({ success: false, message: '账号不存在' });
}
const tokenValue = (body.token || '').trim();
if (tokenValue) {
const now = new Date().toISOString();
const existing = db.select().from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, body.accountId))
.all();
const created = db.insert(schema.accountTokens).values({
accountId: body.accountId,
name: (body.name || '').trim() || (existing.length === 0 ? 'default' : `token-${existing.length + 1}`),
token: tokenValue,
source: body.source || 'manual',
enabled: body.enabled ?? true,
isDefault: body.isDefault ?? false,
createdAt: now,
updatedAt: now,
}).returning().get();
if (body.isDefault || (existing.length === 0 && (body.enabled ?? true))) {
setDefaultToken(created.id);
} else if (existing.every((token) => !token.isDefault) && (body.enabled ?? true)) {
setDefaultToken(created.id);
}
return { success: true, token: created };
}
const account = row.accounts;
const site = row.sites;
if (isSiteDisabled(site.status)) {
return reply.code(400).send({ success: false, message: '站点已禁用,无法创建令牌' });
}
if (!account.accessToken?.trim()) {
return reply.code(400).send({ success: false, message: '账号缺少访问令牌,无法创建站点令牌' });
}
const adapter = getAdapter(site.platform);
if (!adapter) {
return reply.code(400).send({ success: false, message: `不支持的平台: ${site.platform}` });
}
const unlimitedQuota = body.unlimitedQuota === undefined
? undefined
: parseOptionalBoolean(body.unlimitedQuota);
if (body.unlimitedQuota !== undefined && unlimitedQuota === undefined) {
return reply.code(400).send({ success: false, message: 'unlimitedQuota 参数无效' });
}
const remainQuota = body.remainQuota === undefined
? undefined
: parsePositiveInteger(body.remainQuota);
if (body.remainQuota !== undefined && remainQuota === undefined) {
return reply.code(400).send({ success: false, message: 'remainQuota 必须是正整数' });
}
if (unlimitedQuota === false && remainQuota === undefined) {
return reply.code(400).send({ success: false, message: '有限额度令牌必须填写 remainQuota' });
}
const expiredTime = body.expiredTime === undefined
? undefined
: parseExpiredTime(body.expiredTime);
if (body.expiredTime !== undefined && expiredTime === undefined) {
return reply.code(400).send({ success: false, message: 'expiredTime 参数无效' });
}
const modelLimitsEnabled = body.modelLimitsEnabled === undefined
? undefined
: parseOptionalBoolean(body.modelLimitsEnabled);
if (body.modelLimitsEnabled !== undefined && modelLimitsEnabled === undefined) {
return reply.code(400).send({ success: false, message: 'modelLimitsEnabled 参数无效' });
}
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
const createdViaUpstream = await adapter.createApiToken(
site.url,
account.accessToken,
platformUserId,
{
name: asTrimmedString(body.name),
group: asTrimmedString(body.group),
unlimitedQuota,
remainQuota,
expiredTime,
allowIps: asTrimmedString(body.allowIps),
modelLimitsEnabled,
modelLimits: asTrimmedString(body.modelLimits),
},
);
if (!createdViaUpstream) {
return reply.code(502).send({ success: false, message: '站点创建令牌失败' });
}
const syncResult = await executeAccountTokenSync(row);
appendTokenSyncEvent(syncResult);
if (syncResult.status === 'failed') {
return reply.code(502).send({ success: false, message: syncResult.message || '同步站点令牌失败' });
}
if (syncResult.status === 'skipped') {
return reply.code(502).send({ success: false, message: syncResult.message || '站点未返回可用令牌' });
}
const preferred = db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.isDefault, true)))
.get();
const token = preferred || db.select().from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all()
.slice(-1)[0] || null;
return {
success: true,
createdViaUpstream: true,
...syncResult,
token,
};
});
app.put<{ Params: { id: string }; Body: { name?: string; token?: string; enabled?: boolean; isDefault?: boolean; source?: string } }>('/api/account-tokens/:id', async (request, reply) => {
const tokenId = Number.parseInt(request.params.id, 10);
if (Number.isNaN(tokenId)) {
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
}
const existing = db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
if (!existing) {
return reply.code(404).send({ success: false, message: '令牌不存在' });
}
const body = request.body;
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() };
if (body.name !== undefined) {
updates.name = (body.name || '').trim() || existing.name;
}
if (body.token !== undefined) {
const tokenValue = body.token.trim();
if (!tokenValue) {
return reply.code(400).send({ success: false, message: '令牌不能为空' });
}
updates.token = tokenValue;
}
if (body.enabled !== undefined) updates.enabled = body.enabled;
if (body.source !== undefined) updates.source = body.source;
if (body.isDefault !== undefined) updates.isDefault = body.isDefault;
db.update(schema.accountTokens).set(updates).where(eq(schema.accountTokens.id, tokenId)).run();
const latest = db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
if (!latest) {
return reply.code(500).send({ success: false, message: '更新失败' });
}
if (body.isDefault === true) {
setDefaultToken(tokenId);
} else if (latest.isDefault && latest.enabled) {
setDefaultToken(tokenId);
} else if (existing.isDefault && !latest.enabled) {
repairDefaultToken(existing.accountId);
} else if (body.isDefault === false && existing.isDefault) {
repairDefaultToken(existing.accountId);
}
return { success: true, token: latest };
});
app.post<{ Params: { id: string } }>('/api/account-tokens/:id/default', async (request, reply) => {
const tokenId = Number.parseInt(request.params.id, 10);
if (Number.isNaN(tokenId)) {
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
}
const success = setDefaultToken(tokenId);
if (!success) {
return reply.code(404).send({ success: false, message: '令牌不存在' });
}
return { success: true };
});
app.get<{ Params: { id: string } }>('/api/account-tokens/:id/value', async (request, reply) => {
const tokenId = Number.parseInt(request.params.id, 10);
if (Number.isNaN(tokenId)) {
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
}
const row = db.select()
.from(schema.accountTokens)
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accountTokens.id, tokenId))
.get();
if (!row) {
return reply.code(404).send({ success: false, message: '令牌不存在' });
}
const tokenValue = normalizeTokenForDisplay(row.account_tokens.token, row.sites.platform);
return {
success: true,
id: row.account_tokens.id,
name: row.account_tokens.name,
token: tokenValue,
tokenMasked: maskToken(row.account_tokens.token, row.sites.platform),
};
});
app.get<{ Params: { accountId: string } }>('/api/account-tokens/groups/:accountId', async (request, reply) => {
const accountId = Number.parseInt(request.params.accountId, 10);
if (Number.isNaN(accountId)) {
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
}
const row = db.select()
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
.get();
if (!row) {
return reply.code(404).send({ success: false, message: '账号不存在' });
}
const account = row.accounts;
const site = row.sites;
const adapter = getAdapter(site.platform);
if (!adapter) {
return reply.code(400).send({ success: false, message: `不支持的平台: ${site.platform}` });
}
if (!account.accessToken?.trim()) {
return reply.code(400).send({ success: false, message: '账号缺少访问令牌,无法拉取分组' });
}
try {
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
const groups = await adapter.getUserGroups(site.url, account.accessToken, platformUserId);
const normalized = Array.from(new Set((groups || []).map((item) => String(item || '').trim()).filter(Boolean)));
return { success: true, groups: normalized.length > 0 ? normalized : ['default'] };
} catch (error: any) {
return reply.code(502).send({
success: false,
message: error?.message || '拉取分组失败',
});
}
});
app.delete<{ Params: { id: string } }>('/api/account-tokens/:id', async (request, reply) => {
const tokenId = Number.parseInt(request.params.id, 10);
if (Number.isNaN(tokenId)) {
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
}
const row = db.select()
.from(schema.accountTokens)
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accountTokens.id, tokenId))
.get();
if (!row) {
return reply.code(404).send({ success: false, message: '令牌不存在' });
}
const existing = row.account_tokens;
const account = row.accounts;
const site = row.sites;
const adapter = getAdapter(site.platform);
const shouldDeleteUpstream = !isSiteDisabled(site.status) && !!account.accessToken?.trim() && !!adapter;
if (shouldDeleteUpstream) {
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
const upstreamDeleted = await adapter!.deleteApiToken(
site.url,
account.accessToken,
existing.token,
platformUserId,
);
if (!upstreamDeleted) {
return reply.code(502).send({ success: false, message: '站点删除令牌失败,本地未删除' });
}
}
db.delete(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).run();
if (existing.isDefault) {
repairDefaultToken(existing.accountId);
}
return { success: true };
});
app.post<{ Params: { accountId: string } }>('/api/account-tokens/sync/:accountId', async (request, reply) => {
const accountId = Number.parseInt(request.params.accountId, 10);
if (Number.isNaN(accountId)) {
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
}
const row = db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
.get();
if (!row) {
return reply.code(404).send({ success: false, message: '账号不存在' });
}
const result = await executeAccountTokenSync(row);
appendTokenSyncEvent(result);
if (result.status === 'failed' && result.reason === 'unsupported_platform') {
return reply.code(400).send({ success: false, message: result.message });
}
if (result.status === 'failed') {
return reply.code(502).send({ success: false, message: result.message || '同步失败' });
}
return { success: true, ...result };
});
app.post<{ Body?: { wait?: boolean } }>('/api/account-tokens/sync-all', async (request, reply) => {
if (request.body?.wait) {
const syncResult = await executeSyncAllAccountTokens();
return { success: true, ...syncResult };
}
const { task, reused } = startBackgroundTask(
{
type: 'token',
title: '同步全部账号令牌',
dedupeKey: 'sync-all-account-tokens',
notifyOnFailure: true,
successTitle: (currentTask) => {
const summary = (currentTask.result as any)?.summary;
if (!summary) return '同步全部账号令牌已完成';
return `同步全部账号令牌已完成(成功${summary.synced}/跳过${summary.skipped}/失败${summary.failed})`;
},
failureTitle: () => '同步全部账号令牌失败',
successMessage: (currentTask) => {
const summary = (currentTask.result as any)?.summary;
const results = (currentTask.result as any)?.results as SyncExecutionResult[] | undefined;
if (!summary) return '全部账号令牌同步任务已完成';
const detail = buildTokenSyncTaskDetailMessage(Array.isArray(results) ? results : []);
return detail
? `全部账号令牌同步完成:成功 ${summary.synced},跳过 ${summary.skipped},失败 ${summary.failed}\n${detail}`
: `全部账号令牌同步完成:成功 ${summary.synced},跳过 ${summary.skipped},失败 ${summary.failed}`;
},
failureMessage: (currentTask) => `全部账号令牌同步失败:${currentTask.error || 'unknown error'}`,
},
async () => executeSyncAllAccountTokens(),
);
return reply.code(202).send({
success: true,
queued: true,
reused,
jobId: task.id,
status: task.status,
message: reused
? '令牌同步任务执行中,请稍后查看程序日志'
: '已开始全部账号令牌同步,请稍后查看程序日志',
});
});
app.get<{ Params: { accountId: string } }>('/api/account-tokens/account/:accountId/default', async (request, reply) => {
const accountId = Number.parseInt(request.params.accountId, 10);
if (Number.isNaN(accountId)) {
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
}
const row = db.select()
.from(schema.accountTokens)
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.isDefault, true)))
.get();
return {
success: true,
token: row
? (() => {
const { token: rawToken, ...meta } = row.account_tokens;
return { ...meta, tokenMasked: maskToken(rawToken, row.sites.platform) };
})()
: null,
};
});
}
@@ -0,0 +1,104 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const refreshBalanceMock = vi.fn();
vi.mock('../../services/balanceService.js', () => ({
refreshBalance: (...args: unknown[]) => refreshBalanceMock(...args),
}));
type DbModule = typeof import('../../db/index.js');
describe('accounts health refresh runtime state', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-health-refresh-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accounts.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.accountsRoutes);
});
beforeEach(() => {
refreshBalanceMock.mockReset();
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('keeps degraded runtime state for unsupported checkin after health refresh', async () => {
const site = db.insert(schema.sites).values({
name: 'Wind Hub',
url: 'https://windhub.cc',
platform: 'done-hub',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'ld6jl3djexjf',
accessToken: 'token',
status: 'active',
extraConfig: JSON.stringify({
runtimeHealth: {
state: 'degraded',
reason: '站点不支持签到接口',
source: 'checkin',
checkedAt: '2026-02-25T18:00:00.000Z',
},
}),
}).returning().get();
refreshBalanceMock.mockResolvedValueOnce({ balance: 100, used: 0, quota: 100 });
const response = await app.inject({
method: 'POST',
url: '/api/accounts/health/refresh',
payload: { accountId: account.id, wait: true },
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
success: boolean;
summary: {
healthy: number;
degraded: number;
failed: number;
};
results: Array<{ state: string; status: string; message: string }>;
};
expect(body.success).toBe(true);
expect(body.summary.degraded).toBe(1);
expect(body.summary.healthy).toBe(0);
expect(body.summary.failed).toBe(0);
expect(body.results[0]).toMatchObject({
state: 'degraded',
status: 'success',
message: '站点不支持签到接口',
});
});
});
@@ -0,0 +1,185 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
import {
formatLocalDate,
formatUtcSqlDateTime,
getLocalDayRangeUtc,
parseStoredUtcDateTime,
} from '../../services/localTimeService.js';
type DbModule = typeof import('../../db/index.js');
describe('accounts api today reward fallback', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-reward-fallback-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accounts.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.accountsRoutes);
});
beforeEach(() => {
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('uses today income value when checkin reward is missing', async () => {
const today = formatLocalDate(new Date());
const site = db.insert(schema.sites).values({
name: 'reward-site',
url: 'https://reward-site.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'reward-user',
accessToken: 'token',
status: 'active',
extraConfig: JSON.stringify({
todayIncomeSnapshot: {
day: today,
baseline: 12.5,
latest: 12.5,
updatedAt: `${today}T10:00:00.000Z`,
},
}),
}).returning().get();
db.insert(schema.checkinLogs).values({
accountId: account.id,
status: 'success',
message: 'checked in',
reward: '',
createdAt: `${today} 10:01:00`,
}).run();
const response = await app.inject({
method: 'GET',
url: '/api/accounts',
});
expect(response.statusCode).toBe(200);
const rows = response.json() as Array<{ id: number; todayReward: number }>;
const target = rows.find((row) => row.id === account.id);
expect(target?.todayReward).toBe(12.5);
});
it('prefers parsed checkin reward when available', async () => {
const today = formatLocalDate(new Date());
const site = db.insert(schema.sites).values({
name: 'reward-site',
url: 'https://reward-site.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'reward-user',
accessToken: 'token',
status: 'active',
extraConfig: JSON.stringify({
todayIncomeSnapshot: {
day: today,
baseline: 10,
latest: 14,
updatedAt: `${today}T10:00:00.000Z`,
},
}),
}).returning().get();
db.insert(schema.checkinLogs).values({
accountId: account.id,
status: 'success',
message: 'checkin success',
reward: '1.2',
createdAt: `${today} 10:01:00`,
}).run();
const response = await app.inject({
method: 'GET',
url: '/api/accounts',
});
expect(response.statusCode).toBe(200);
const rows = response.json() as Array<{ id: number; todayReward: number }>;
const target = rows.find((row) => row.id === account.id);
expect(target?.todayReward).toBe(1.2);
});
it('counts today spend only inside local-day range', async () => {
const site = db.insert(schema.sites).values({
name: 'spend-site',
url: 'https://spend-site.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'spend-user',
accessToken: 'token',
status: 'active',
}).returning().get();
const { startUtc, endUtc } = getLocalDayRangeUtc();
const startDate = parseStoredUtcDateTime(startUtc)!;
const endDate = parseStoredUtcDateTime(endUtc)!;
const beforeStart = formatUtcSqlDateTime(new Date(startDate.getTime() - 60_000));
const inRange = formatUtcSqlDateTime(new Date(startDate.getTime() + 60_000));
const afterEnd = formatUtcSqlDateTime(new Date(endDate.getTime() + 60_000));
db.insert(schema.proxyLogs).values([
{
accountId: account.id,
status: 'success',
estimatedCost: 1,
createdAt: beforeStart,
},
{
accountId: account.id,
status: 'success',
estimatedCost: 2,
createdAt: inRange,
},
{
accountId: account.id,
status: 'success',
estimatedCost: 4,
createdAt: afterEnd,
},
]).run();
const response = await app.inject({
method: 'GET',
url: '/api/accounts',
});
expect(response.statusCode).toBe(200);
const rows = response.json() as Array<{ id: number; todaySpend: number }>;
const target = rows.find((row) => row.id === account.id);
expect(target?.todaySpend).toBe(2);
});
});
+575
View File
@@ -0,0 +1,575 @@
import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, eq, gte, lt } from 'drizzle-orm';
import { refreshBalance } from '../../services/balanceService.js';
import { getAdapter } from '../../services/platforms/index.js';
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../../services/modelService.js';
import { ensureDefaultTokenForAccount, syncTokensFromUpstream } from '../../services/accountTokenService.js';
import { guessPlatformUserIdFromUsername, mergeAccountExtraConfig } from '../../services/accountExtraConfig.js';
import { encryptAccountPassword } from '../../services/accountCredentialService.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js';
import { getLocalDayRangeUtc } from '../../services/localTimeService.js';
import {
buildRuntimeHealthForAccount,
setAccountRuntimeHealth,
type RuntimeHealthState,
} from '../../services/accountHealthService.js';
type AccountWithSiteRow = {
accounts: typeof schema.accounts.$inferSelect;
sites: typeof schema.sites.$inferSelect;
};
type AccountHealthRefreshResult = {
accountId: number;
username: string | null;
siteName: string;
status: 'success' | 'failed' | 'skipped';
state: RuntimeHealthState;
message: string;
};
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,
};
}
async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<AccountHealthRefreshResult> {
const accountId = row.accounts.id;
const username = row.accounts.username;
const siteName = row.sites.name;
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: '账号或站点已禁用',
};
}
try {
await refreshBalance(accountId);
const refreshedAccount = db.select().from(schema.accounts)
.where(eq(schema.accounts.id, accountId))
.get();
const runtimeHealth = buildRuntimeHealthForAccount({
accountStatus: refreshedAccount?.status || row.accounts.status,
siteStatus: row.sites.status,
extraConfig: refreshedAccount?.extraConfig ?? row.accounts.extraConfig,
});
return {
accountId,
username,
siteName,
status: runtimeHealth.state === 'unhealthy' ? 'failed' : 'success',
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 = 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 = db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)).all();
const { localDay, startUtc, endUtc } = getLocalDayRangeUtc();
// Aggregate today's spend per account from proxy logs
const todayLogs = db.select().from(schema.proxyLogs)
.where(and(gte(schema.proxyLogs.createdAt, startUtc), lt(schema.proxyLogs.createdAt, endUtc)))
.all();
const spendByAccount: Record<number, number> = {};
for (const log of todayLogs) {
if (log.accountId == null) continue;
const cost = typeof log.estimatedCost === 'number' ? log.estimatedCost : 0;
spendByAccount[log.accountId] = (spendByAccount[log.accountId] || 0) + cost;
}
// Aggregate today's checkin rewards per account
const todayCheckins = db.select().from(schema.checkinLogs)
.where(and(
gte(schema.checkinLogs.createdAt, startUtc),
lt(schema.checkinLogs.createdAt, endUtc),
eq(schema.checkinLogs.status, 'success'),
))
.all();
const rewardByAccount: Record<number, number> = {};
const successCountByAccount: Record<number, number> = {};
const parsedRewardCountByAccount: Record<number, number> = {};
for (const log of todayCheckins) {
successCountByAccount[log.accountId] = (successCountByAccount[log.accountId] || 0) + 1;
const rewardNum = parseCheckinRewardAmount(log.reward) || parseCheckinRewardAmount(log.message);
if (rewardNum <= 0) continue;
rewardByAccount[log.accountId] = (rewardByAccount[log.accountId] || 0) + rewardNum;
parsedRewardCountByAccount[log.accountId] = (parsedRewardCountByAccount[log.accountId] || 0) + 1;
}
return rows.map((r) => ({
...r.accounts,
site: r.sites,
todaySpend: Math.round((spendByAccount[r.accounts.id] || 0) * 1_000_000) / 1_000_000,
todayReward: Math.round(estimateRewardWithTodayIncomeFallback({
day: localDay,
successCount: successCountByAccount[r.accounts.id] || 0,
parsedRewardCount: parsedRewardCountByAccount[r.accounts.id] || 0,
rewardSum: rewardByAccount[r.accounts.id] || 0,
extraConfig: r.accounts.extraConfig,
}) * 1_000_000) / 1_000_000,
runtimeHealth: buildRuntimeHealthForAccount({
accountStatus: r.accounts.status,
siteStatus: r.sites.status,
extraConfig: r.accounts.extraConfig,
}),
}));
});
// 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 = 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) {
return { success: false, message: loginResult.message || 'login failed' };
}
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 = db.select().from(schema.accounts)
.where(and(eq(schema.accounts.siteId, siteId), eq(schema.accounts.username, username)))
.get();
const extraConfigPatch: Record<string, unknown> = {
autoRelogin: {
username,
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) {
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 created = db.insert(schema.accounts).values({
siteId,
username,
accessToken: loginResult.accessToken,
apiToken: preferredApiToken || undefined,
checkinEnabled: true,
extraConfig,
}).returning().get();
accountId = created.id;
}
const result = 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 {
syncTokensFromUpstream(result.id, apiTokens);
} catch { }
} else if (preferredApiToken) {
try {
ensureDefaultTokenForAccount(result.id, preferredApiToken, { name: 'default', source: 'sync' });
} catch { }
}
// Auto-refresh balance
try { await refreshBalance(result.id); } catch { }
try {
await refreshModelsForAccount(result.id);
rebuildTokenRoutesFromAvailability();
} catch { }
const account = 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 a token against a site - auto-detects token type (session vs API key)
app.post<{ Body: { siteId: number; accessToken: string; platformUserId?: number } }>('/api/accounts/verify-token', async (request) => {
const { siteId, accessToken, platformUserId } = request.body;
const site = db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
if (!site) return { success: false, message: 'site not found' };
const adapter = getAdapter(site.platform);
if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
const result = await adapter.verifyToken(site.url, accessToken, platformUserId);
if (result.tokenType === 'session') {
return {
success: true,
tokenType: 'session',
userInfo: result.userInfo,
balance: result.balance,
apiToken: result.apiToken,
};
}
if (result.tokenType === 'apikey') {
return {
success: true,
tokenType: 'apikey',
modelCount: result.models?.length || 0,
models: result.models?.slice(0, 10),
};
}
// Try to explain unknown failures: missing user id vs anti-bot challenge page.
type VerifyFailureReason = 'needs-user-id' | 'shield-blocked' | null;
const detectVerifyFailureReason = async (): Promise<VerifyFailureReason> => {
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
const text = bodyText || '';
const ct = (contentType || '').toLowerCase();
if (ct.includes('text/html') && /var\s+arg1\s*=|acw_sc__v2|cdn_sec_tc|<script/i.test(text)) {
return 'shield-blocked';
}
try {
const body = JSON.parse(text) as any;
const message = typeof body?.message === 'string' ? body.message : '';
if (/mismatch|new-api-user|user id/i.test(message)) return 'needs-user-id';
if (/shield|challenge|captcha|acw_sc__v2|arg1/i.test(message)) return 'shield-blocked';
} catch { }
return null;
};
try {
const { fetch } = await import('undici');
const candidates = new Set<string>();
const trimmed = (accessToken || '').trim();
const raw = trimmed.startsWith('Bearer ') ? trimmed.slice(7).trim() : trimmed;
if (raw) {
if (raw.includes('=')) candidates.add(raw);
candidates.add(`session=${raw}`);
candidates.add(`token=${raw}`);
}
const headerVariants: Record<string, string>[] = [
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': '0' },
];
for (const cookie of candidates) {
headerVariants.push({
Cookie: cookie,
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
});
}
for (const headers of headerVariants) {
try {
const testRes = await fetch(`${site.url}/api/user/self`, { headers });
const bodyText = await testRes.text();
const contentType = testRes.headers.get('content-type') || '';
const reason = parseFailureReason(bodyText, contentType);
if (reason) return reason;
} catch { }
}
} catch { }
return null;
};
const failureReason = await detectVerifyFailureReason();
if (failureReason === 'needs-user-id') {
return {
success: false,
needsUserId: true,
message: 'This site requires a user ID. Please fill in your site user ID.',
};
}
if (failureReason === 'shield-blocked') {
return {
success: false,
shieldBlocked: true,
message: 'This site is shielded by anti-bot challenge. Create an API key on the target site and import that key.',
};
}
return {
success: false,
message: 'Token invalid: cannot use it as session token or API key',
};
});
// Add an account (manual token input) - auto-detects token type and fetches info
app.post<{ Body: { siteId: number; username?: string; accessToken: string; apiToken?: string; platformUserId?: number; checkinEnabled?: boolean } }>('/api/accounts', async (request) => {
const body = request.body;
const site = db.select().from(schema.sites).where(eq(schema.sites.id, body.siteId)).get();
let username = body.username;
let accessToken = body.accessToken;
let apiToken = body.apiToken;
let tokenType = 'unknown';
if (site) {
const adapter = getAdapter(site.platform);
if (adapter) {
const verifyResult = await adapter.verifyToken(site.url, body.accessToken, body.platformUserId);
tokenType = verifyResult.tokenType;
if (verifyResult.tokenType === 'session') {
// Token is a session token - can do management ops
if (!username && verifyResult.userInfo?.username) username = verifyResult.userInfo.username;
if (!apiToken && verifyResult.apiToken) apiToken = verifyResult.apiToken;
} else if (verifyResult.tokenType === 'apikey') {
// Token is an API key - store as apiToken, not accessToken
apiToken = body.accessToken;
accessToken = ''; // no session token available
}
}
}
// Store platformUserId in extraConfig for NewAPI sites that need it
const resolvedPlatformUserId =
body.platformUserId || guessPlatformUserIdFromUsername(username) || undefined;
let extraConfig: string | undefined;
if (resolvedPlatformUserId) {
extraConfig = mergeAccountExtraConfig(undefined, { platformUserId: resolvedPlatformUserId });
}
const result = db.insert(schema.accounts).values({
siteId: body.siteId,
username: username || undefined,
accessToken,
apiToken: apiToken || undefined,
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
extraConfig,
}).returning().get();
if (apiToken) {
try {
ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' });
} catch { }
}
if (tokenType === 'session' && accessToken && site) {
try {
const adapter = getAdapter(site.platform);
if (adapter) {
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, resolvedPlatformUserId);
if (syncedTokens.length > 0) {
syncTokensFromUpstream(result.id, syncedTokens);
}
}
} catch { }
}
// Try to refresh balance
if (tokenType === 'session') {
try { await refreshBalance(result.id); } catch { }
}
try {
await refreshModelsForAccount(result.id);
rebuildTokenRoutesFromAvailability();
} catch { }
const account = db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
return { ...account, tokenType, apiTokenFound: !!apiToken, usernameDetected: !!(!body.username && username) };
});
// Update an account
app.put<{ Params: { id: string }; Body: any }>('/api/accounts/:id', async (request) => {
const id = parseInt(request.params.id);
const body = request.body as Record<string, unknown>;
const updates: any = {};
for (const key of ['username', 'accessToken', 'apiToken', 'status', 'checkinEnabled', 'unitCost', 'extraConfig']) {
if (body[key] !== undefined) updates[key] = body[key];
}
updates.updatedAt = new Date().toISOString();
db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
if (typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
try {
ensureDefaultTokenForAccount(id, updates.apiToken, { name: 'default', source: 'manual' });
} catch { }
}
try {
await refreshModelsForAccount(id);
rebuildTokenRoutesFromAvailability();
} catch { }
return 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);
db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run();
try {
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' };
}
});
}
+56
View File
@@ -0,0 +1,56 @@
import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { config } from '../../config.js';
import { eq } from 'drizzle-orm';
export async function authRoutes(app: FastifyInstance) {
// Change admin auth token (requires old token verification)
app.post<{ Body: { oldToken: string; newToken: string } }>('/api/settings/auth/change', async (request, reply) => {
const { oldToken, newToken } = request.body;
if (!oldToken || !newToken) {
return reply.code(400).send({ success: false, message: '请填写所有字段' });
}
if (newToken.length < 6) {
return reply.code(400).send({ success: false, message: '新 Token 至少 6 个字符' });
}
if (oldToken !== config.authToken) {
return reply.code(403).send({ success: false, message: '旧 Token 验证失败' });
}
// Save to settings table
const existing = db.select().from(schema.settings).where(eq(schema.settings.key, 'auth_token')).get();
if (existing) {
db.update(schema.settings).set({ value: JSON.stringify(newToken) }).where(eq(schema.settings.key, 'auth_token')).run();
} else {
db.insert(schema.settings).values({ key: 'auth_token', value: JSON.stringify(newToken) }).run();
}
// Update runtime config
config.authToken = newToken;
try {
db.insert(schema.events).values({
type: 'token',
title: '管理员登录令牌已更新',
message: '管理员登录 Token 已被修改,请使用新 Token 登录。',
level: 'warning',
relatedType: 'settings',
createdAt: new Date().toISOString(),
}).run();
} catch {}
return { success: true, message: 'Token 已更新' };
});
// Get masked current token (for display)
app.get('/api/settings/auth/info', async () => {
const token = config.authToken;
const masked = token.length > 8
? token.slice(0, 4) + '****' + token.slice(-4)
: '****';
return { masked };
});
}
@@ -0,0 +1,90 @@
import Fastify from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const checkinAllMock = vi.fn();
const checkinAccountMock = vi.fn();
vi.mock('../../services/checkinService.js', () => ({
checkinAll: (...args: unknown[]) => checkinAllMock(...args),
checkinAccount: (...args: unknown[]) => checkinAccountMock(...args),
}));
vi.mock('../../services/checkinScheduler.js', () => ({
updateCheckinCron: vi.fn(),
}));
vi.mock('../../db/index.js', () => {
const insertChain = {
values: () => insertChain,
onConflictDoUpdate: () => insertChain,
run: () => ({ changes: 1 }),
};
const queryChain = {
where: () => queryChain,
all: () => [],
limit: () => queryChain,
offset: () => queryChain,
orderBy: () => queryChain,
innerJoin: () => queryChain,
from: () => queryChain,
};
return {
db: {
insert: () => insertChain,
select: () => queryChain,
},
schema: {
settings: { key: 'key' },
checkinLogs: { accountId: 'accountId', createdAt: 'createdAt' },
accounts: { id: 'id' },
events: { id: 'id' },
},
};
});
describe('POST /api/checkin/trigger background task dedupe', () => {
beforeEach(async () => {
checkinAllMock.mockReset();
checkinAccountMock.mockReset();
checkinAccountMock.mockResolvedValue({ success: true, message: 'ok' });
const { __resetBackgroundTasksForTests } = await import('../../services/backgroundTaskService.js');
__resetBackgroundTasksForTests();
});
afterEach(() => {
vi.clearAllMocks();
});
it('reuses the same background task while checkin-all is already running', async () => {
let resolveFirst: (value: Array<unknown>) => void = () => {};
const firstRun = new Promise<Array<unknown>>((resolve) => {
resolveFirst = resolve;
});
checkinAllMock.mockImplementation(() => firstRun);
const { checkinRoutes } = await import('./checkin.js');
const app = Fastify();
await app.register(checkinRoutes);
const firstResponse = await app.inject({ method: 'POST', url: '/api/checkin/trigger' });
expect(firstResponse.statusCode).toBe(202);
const firstBody = firstResponse.json() as { success: boolean; queued: boolean; jobId: string };
expect(firstBody.success).toBe(true);
expect(firstBody.queued).toBe(true);
expect(typeof firstBody.jobId).toBe('string');
expect(firstBody.jobId.length).toBeGreaterThan(10);
const secondResponse = await app.inject({ method: 'POST', url: '/api/checkin/trigger' });
expect(secondResponse.statusCode).toBe(202);
const secondBody = secondResponse.json() as { reused: boolean; jobId: string };
expect(secondBody.reused).toBe(true);
expect(secondBody.jobId).toBe(firstBody.jobId);
expect(checkinAllMock).toHaveBeenCalledTimes(1);
resolveFirst([]);
await new Promise((resolve) => setTimeout(resolve, 20));
await app.close();
});
});
+154
View File
@@ -0,0 +1,154 @@
import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { eq, desc } from 'drizzle-orm';
import { checkinAccount, checkinAll } from '../../services/checkinService.js';
import { updateCheckinCron } from '../../services/checkinScheduler.js';
import { startBackgroundTask, summarizeCheckinResults } from '../../services/backgroundTaskService.js';
import { classifyFailureReason } from '../../services/failureReasonService.js';
function buildCheckinAccountLabel(item: any): string {
const username = item?.username || (item?.accountId ? `#${item.accountId}` : 'unknown');
const site = item?.site || 'unknown-site';
return `${username} @ ${site}`;
}
function buildCheckinReason(item: any): string {
const message = String(item?.result?.message || '').trim();
if (!message) return '';
if (message.length <= 32) return message;
return `${message.slice(0, 32)}...`;
}
function buildCheckinTaskDetailMessage(results: any[]): string {
if (!Array.isArray(results) || results.length === 0) return '';
const successRows = results.filter((item) => {
const status = item?.result?.status;
if (status === 'skipped' || item?.result?.skipped) return false;
return !!item?.result?.success;
});
const skippedRows = results.filter((item) => {
const status = item?.result?.status;
return status === 'skipped' || !!item?.result?.skipped;
});
const failedRows = results.filter((item) => {
const status = item?.result?.status;
if (status === 'skipped' || item?.result?.skipped) return false;
return !item?.result?.success;
});
const renderRows = (rows: any[], withReason = false) => {
const sliced = rows.slice(0, 12).map((item) => {
const base = buildCheckinAccountLabel(item);
if (!withReason) return base;
const reason = buildCheckinReason(item);
return reason ? `${base}(${reason})` : base;
});
if (rows.length > 12) sliced.push(`...等${rows.length}个`);
return sliced.join('、');
};
const segments: string[] = [
`成功(${successRows.length}): ${successRows.length > 0 ? renderRows(successRows) : '-'}`,
`跳过(${skippedRows.length}): ${skippedRows.length > 0 ? renderRows(skippedRows, true) : '-'}`,
`失败(${failedRows.length}): ${failedRows.length > 0 ? renderRows(failedRows, true) : '-'}`,
];
return segments.join('\n');
}
export async function checkinRoutes(app: FastifyInstance) {
// Trigger check-in for all accounts
app.post('/api/checkin/trigger', async (_, reply) => {
const { task, reused } = startBackgroundTask(
{
type: 'checkin',
title: '全部账号签到',
dedupeKey: 'checkin-all',
notifyOnFailure: true,
successTitle: (currentTask) => {
const summary = (currentTask.result as any)?.summary;
if (!summary) return '全部账号签到已完成';
return `全部账号签到已完成(成功${summary.success}/跳过${summary.skipped}/失败${summary.failed})`;
},
failureTitle: () => '全部账号签到失败',
successMessage: (currentTask) => {
const summary = (currentTask.result as any)?.summary;
const results = (currentTask.result as any)?.results;
if (!summary) return '全部账号签到任务已完成';
const detail = buildCheckinTaskDetailMessage(Array.isArray(results) ? results : []);
return detail
? `全部账号签到完成:成功 ${summary.success},跳过 ${summary.skipped},失败 ${summary.failed}\n${detail}`
: `全部账号签到完成:成功 ${summary.success},跳过 ${summary.skipped},失败 ${summary.failed}`;
},
failureMessage: (currentTask) => `全部账号签到任务失败:${currentTask.error || 'unknown error'}`,
},
async () => {
const results = await checkinAll();
return {
summary: summarizeCheckinResults(results),
total: results.length,
results,
};
},
);
return reply.code(202).send({
success: true,
queued: true,
reused,
jobId: task.id,
status: task.status,
message: reused
? '签到任务执行中,请稍后查看签到日志'
: '已开始全部签到,请稍后查看签到日志',
});
});
// Trigger check-in for a specific account
app.post<{ Params: { id: string } }>('/api/checkin/trigger/:id', async (request) => {
const id = parseInt(request.params.id, 10);
const result = await checkinAccount(id);
return result;
});
// Get check-in logs
app.get<{ Querystring: { limit?: string; offset?: string; accountId?: string } }>('/api/checkin/logs', async (request) => {
const limit = parseInt(request.query.limit || '50', 10);
const offset = parseInt(request.query.offset || '0', 10);
let query = db.select().from(schema.checkinLogs)
.innerJoin(schema.accounts, eq(schema.checkinLogs.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.orderBy(desc(schema.checkinLogs.createdAt))
.limit(limit)
.offset(offset);
if (request.query.accountId) {
query = query.where(eq(schema.checkinLogs.accountId, parseInt(request.query.accountId, 10))) as any;
}
const rows = query.all();
return rows.map((row: any) => {
const source = row?.checkin_logs || row;
const failureReason = classifyFailureReason({
message: source?.message,
status: source?.status,
});
return {
...row,
failureReason,
};
});
});
// Update check-in schedule
app.put<{ Body: { cron: string } }>('/api/checkin/schedule', async (request) => {
try {
updateCheckinCron(request.body.cron);
db.insert(schema.settings).values({ key: 'checkin_cron', value: JSON.stringify(request.body.cron) })
.onConflictDoUpdate({ target: schema.settings.key, set: { value: JSON.stringify(request.body.cron) } }).run();
return { success: true, cron: request.body.cron };
} catch (err: any) {
return { error: err.message };
}
});
}
+60
View File
@@ -0,0 +1,60 @@
import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, desc, eq, sql } from 'drizzle-orm';
export async function eventsRoutes(app: FastifyInstance) {
// List events
app.get<{ Querystring: { limit?: string; offset?: string; type?: string; read?: string } }>('/api/events', async (request) => {
const limit = Math.max(1, Math.min(500, parseInt(request.query.limit || '30', 10)));
const offset = Math.max(0, parseInt(request.query.offset || '0', 10));
const type = request.query.type;
const readQuery = request.query.read;
const filters = [];
if (type) filters.push(eq(schema.events.type, type));
if (readQuery === 'true') filters.push(eq(schema.events.read, true));
if (readQuery === 'false') filters.push(eq(schema.events.read, false));
const base = db.select().from(schema.events);
if (filters.length > 0) {
return base
.where(and(...filters))
.orderBy(desc(schema.events.createdAt))
.limit(limit)
.offset(offset)
.all();
}
return base
.orderBy(desc(schema.events.createdAt))
.limit(limit)
.offset(offset)
.all();
});
// Unread count
app.get('/api/events/count', async () => {
const result = db.select({ count: sql<number>`count(*)` }).from(schema.events)
.where(eq(schema.events.read, false)).get();
return { count: result?.count || 0 };
});
// Mark one as read
app.post<{ Params: { id: string } }>('/api/events/:id/read', async (request) => {
const id = parseInt(request.params.id);
db.update(schema.events).set({ read: true }).where(eq(schema.events.id, id)).run();
return { success: true };
});
// Mark all as read
app.post('/api/events/read-all', async () => {
db.update(schema.events).set({ read: true }).where(eq(schema.events.read, false)).run();
return { success: true };
});
// Clear all events
app.delete('/api/events', async () => {
db.delete(schema.events).run();
return { success: true };
});
}
+215
View File
@@ -0,0 +1,215 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { db, schema } from '../../db/index.js';
import { config } from '../../config.js';
import { eq } from 'drizzle-orm';
const MONITOR_AUTH_COOKIE = 'meta_monitor_auth';
const LDOH_BASE_URL = 'https://ldoh.105117.xyz';
const LDOH_COOKIE_SETTING_KEY = 'monitor_ldoh_cookie';
function upsertSetting(key: string, value: unknown) {
db.insert(schema.settings)
.values({ key, value: JSON.stringify(value) })
.onConflictDoUpdate({
target: schema.settings.key,
set: { value: JSON.stringify(value) },
})
.run();
}
function getSettingString(key: string): string {
const row = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
if (!row?.value) return '';
try {
const parsed = JSON.parse(row.value);
return typeof parsed === 'string' ? parsed : '';
} catch {
return '';
}
}
function parseCookies(raw: string | undefined): Record<string, string> {
const result: Record<string, string> = {};
if (!raw) return result;
for (const part of raw.split(';')) {
const entry = part.trim();
if (!entry) continue;
const idx = entry.indexOf('=');
if (idx <= 0) continue;
const key = entry.slice(0, idx).trim();
const value = entry.slice(idx + 1).trim();
if (!key) continue;
result[key] = value;
}
return result;
}
function maskCookieValue(cookieText: string): string {
const value = cookieText.trim();
if (!value) return '';
const idx = value.indexOf('=');
const raw = idx >= 0 ? value.slice(idx + 1) : value;
if (raw.length <= 10) return `${raw.slice(0, 2)}****`;
return `${raw.slice(0, 6)}****${raw.slice(-4)}`;
}
function normalizeLdohCookie(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return '';
if (trimmed.includes('ld_auth_session=')) {
const firstPair = trimmed.split(';')[0].trim();
if (firstPair.startsWith('ld_auth_session=')) return firstPair;
}
return `ld_auth_session=${trimmed}`;
}
function rewriteProxyText(text: string): string {
return text
.replaceAll('https://ldoh.105117.xyz/', '/monitor-proxy/ldoh/')
.replaceAll('https:\\/\\/ldoh.105117.xyz\\/', '\\/monitor-proxy\\/ldoh\\/')
.replaceAll('src="/', 'src="/monitor-proxy/ldoh/')
.replaceAll("src='/", "src='/monitor-proxy/ldoh/")
.replaceAll('href="/', 'href="/monitor-proxy/ldoh/')
.replaceAll("href='/", "href='/monitor-proxy/ldoh/")
.replaceAll('action="/', 'action="/monitor-proxy/ldoh/')
.replaceAll("action='/", "action='/monitor-proxy/ldoh/")
.replaceAll('"/_next/', '"/monitor-proxy/ldoh/_next/')
.replaceAll("'/_next/", "'/monitor-proxy/ldoh/_next/")
.replaceAll('"\\/api/', '"\\/monitor-proxy\\/ldoh\\/api/')
.replaceAll("'/api/", "'/monitor-proxy/ldoh/api/")
.replaceAll('"/api/', '"/monitor-proxy/ldoh/api/');
}
function rewriteLocationHeader(location: string | null): string | null {
if (!location) return null;
if (location.startsWith(`${LDOH_BASE_URL}/`)) {
return `/monitor-proxy/ldoh/${location.slice(LDOH_BASE_URL.length + 1)}`;
}
if (location.startsWith('/')) {
return `/monitor-proxy/ldoh${location}`;
}
return location;
}
function ensureMonitorAuth(request: FastifyRequest, reply: FastifyReply): boolean {
const cookies = parseCookies(request.headers.cookie);
if (cookies[MONITOR_AUTH_COOKIE] !== config.authToken) {
reply.code(401).send({ error: 'Missing or invalid monitor session' });
return false;
}
return true;
}
function resolveLdohProxyPath(request: FastifyRequest): string {
const rawUrl = String(request.url || '');
const cleanPath = rawUrl.split('?')[0] || '';
const prefix = '/monitor-proxy/ldoh';
if (cleanPath === prefix || cleanPath === `${prefix}/`) return '';
if (cleanPath.startsWith(`${prefix}/`)) return cleanPath.slice(prefix.length + 1);
return String((request.params as Record<string, unknown>)['*'] || '');
}
export async function monitorRoutes(app: FastifyInstance) {
app.get('/api/monitor/config', async () => {
const ldohCookie = getSettingString(LDOH_COOKIE_SETTING_KEY);
return {
ldohCookieConfigured: !!ldohCookie,
ldohCookieMasked: ldohCookie ? maskCookieValue(ldohCookie) : '',
};
});
app.put<{ Body: { ldohCookie?: string | null } }>('/api/monitor/config', async (request, reply) => {
const raw = String(request.body?.ldohCookie || '').trim();
if (!raw) {
upsertSetting(LDOH_COOKIE_SETTING_KEY, '');
return { success: true, message: 'LDOH Cookie 已清空', ldohCookieConfigured: false };
}
const normalized = normalizeLdohCookie(raw);
if (!normalized.startsWith('ld_auth_session=') || normalized.length < 24) {
return reply.code(400).send({ success: false, message: 'Cookie 格式无效,请填写 ld_auth_session 或其值' });
}
upsertSetting(LDOH_COOKIE_SETTING_KEY, normalized);
return {
success: true,
message: 'LDOH Cookie 已保存',
ldohCookieConfigured: true,
ldohCookieMasked: maskCookieValue(normalized),
};
});
app.post('/api/monitor/session', async (_, reply) => {
// HttpOnly cookie for iframe proxy auth within current origin.
reply.header(
'Set-Cookie',
`${MONITOR_AUTH_COOKIE}=${config.authToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=7200`,
);
return { success: true };
});
const handleLdohProxy = async (request: FastifyRequest, reply: FastifyReply) => {
if (!ensureMonitorAuth(request, reply)) return;
const storedCookie = getSettingString(LDOH_COOKIE_SETTING_KEY);
if (!storedCookie) {
return reply.code(400).send('LDOH cookie not configured');
}
const wildcardPath = resolveLdohProxyPath(request);
const targetUrl = new URL(`${LDOH_BASE_URL}/${wildcardPath}`);
for (const [key, value] of Object.entries(request.query as Record<string, unknown>)) {
if (value == null) continue;
targetUrl.searchParams.set(key, String(value));
}
const upstreamHeaders: Record<string, string> = {
cookie: storedCookie,
accept: String(request.headers.accept || '*/*'),
'accept-language': String(request.headers['accept-language'] || 'zh-CN,zh;q=0.9,en;q=0.8'),
'user-agent': String(request.headers['user-agent'] || 'metapiMonitorProxy/1.0'),
};
if (request.headers['content-type']) {
upstreamHeaders['content-type'] = String(request.headers['content-type']);
}
if (request.headers.referer) {
upstreamHeaders.referer = String(request.headers.referer).replace('/monitor-proxy/ldoh', '');
}
const method = request.method.toUpperCase();
const bodyAllowed = !['GET', 'HEAD'].includes(method);
const upstreamResponse = await fetch(targetUrl, {
method,
headers: upstreamHeaders,
body: bodyAllowed ? (request.body as BodyInit | null | undefined) : undefined,
redirect: 'manual',
});
const contentType = upstreamResponse.headers.get('content-type') || '';
const location = rewriteLocationHeader(upstreamResponse.headers.get('location'));
if (location) reply.header('location', location);
if (contentType) reply.header('content-type', contentType);
const cacheControl = upstreamResponse.headers.get('cache-control');
if (cacheControl) reply.header('cache-control', cacheControl);
reply.code(upstreamResponse.status);
if (
contentType.includes('text/html')
|| contentType.includes('application/javascript')
|| contentType.includes('text/javascript')
|| contentType.includes('text/css')
|| contentType.includes('application/json')
) {
const text = await upstreamResponse.text();
return reply.send(rewriteProxyText(text));
}
const buffer = Buffer.from(await upstreamResponse.arrayBuffer());
return reply.send(buffer);
};
app.all('/monitor-proxy/ldoh', handleLdohProxy);
app.all('/monitor-proxy/ldoh/', handleLdohProxy);
app.all('/monitor-proxy/ldoh/*', handleLdohProxy);
}
+98
View File
@@ -0,0 +1,98 @@
import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, like, desc, eq } from 'drizzle-orm';
export async function searchRoutes(app: FastifyInstance) {
app.post<{ Body: { query: string; limit?: number } }>('/api/search', async (request) => {
const { query, limit = 20 } = request.body;
if (!query || query.trim().length === 0) {
return { accounts: [], sites: [], checkinLogs: [], proxyLogs: [], models: [] };
}
const q = `%${query.trim()}%`;
const perCategory = Math.min(Math.ceil(limit / 5), 10);
// Search sites
const sites = db.select().from(schema.sites)
.where(like(schema.sites.name, q))
.limit(perCategory).all()
.concat(
db.select().from(schema.sites)
.where(like(schema.sites.url, q))
.limit(perCategory).all()
);
// Deduplicate by id
const uniqueSites = [...new Map(sites.map(s => [s.id, s])).values()].slice(0, perCategory);
// Search accounts (join with sites for site name)
const accountResults = db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(like(schema.accounts.username, q))
.limit(perCategory).all();
const accounts = accountResults.map(r => ({ ...r.accounts, site: r.sites }));
// Search checkin logs (by message)
const checkinLogs = db.select().from(schema.checkinLogs)
.innerJoin(schema.accounts, eq(schema.checkinLogs.accountId, schema.accounts.id))
.where(like(schema.checkinLogs.message, q))
.orderBy(desc(schema.checkinLogs.createdAt))
.limit(perCategory).all()
.map(r => ({ ...r.checkin_logs, account: r.accounts }));
// Search proxy logs (by model name)
const proxyLogs = db.select().from(schema.proxyLogs)
.where(like(schema.proxyLogs.modelRequested, q))
.orderBy(desc(schema.proxyLogs.createdAt))
.limit(perCategory).all();
// Search models (only keep routable items)
const modelRows = db.select({
modelName: schema.tokenModelAvailability.modelName,
tokenId: schema.accountTokens.id,
accountId: schema.accounts.id,
siteId: schema.sites.id,
})
.from(schema.tokenModelAvailability)
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
like(schema.tokenModelAvailability.modelName, q),
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accounts.status, 'active'),
),
)
.limit(perCategory * 20)
.all();
const modelAgg = new Map<string, { tokenIds: Set<number>; accountIds: Set<number>; siteIds: Set<number> }>();
for (const row of modelRows) {
const key = row.modelName;
if (!modelAgg.has(key)) {
modelAgg.set(key, { tokenIds: new Set(), accountIds: new Set(), siteIds: new Set() });
}
const agg = modelAgg.get(key)!;
agg.tokenIds.add(row.tokenId);
agg.accountIds.add(row.accountId);
agg.siteIds.add(row.siteId);
}
const models = Array.from(modelAgg.entries())
.map(([name, agg]) => ({
name,
accountCount: agg.accountIds.size,
tokenCount: agg.tokenIds.size,
siteCount: agg.siteIds.size,
}))
.sort((a, b) => {
if (b.accountCount !== a.accountCount) return b.accountCount - a.accountCount;
if (b.tokenCount !== a.tokenCount) return b.tokenCount - a.tokenCount;
return a.name.localeCompare(b.name);
})
.slice(0, perCategory);
return { accounts, sites: uniqueSites, checkinLogs, proxyLogs, models };
});
}
@@ -0,0 +1,137 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../../db/index.js');
type ConfigModule = typeof import('../../config.js');
describe('settings and auth events', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let config: ConfigModule['config'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-events-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const configModule = await import('../../config.js');
const settingsRoutesModule = await import('./settings.js');
const authRoutesModule = await import('./auth.js');
db = dbModule.db;
schema = dbModule.schema;
config = configModule.config;
app = Fastify();
await app.register(settingsRoutesModule.settingsRoutes);
await app.register(authRoutesModule.authRoutes);
});
beforeEach(() => {
db.delete(schema.events).run();
db.delete(schema.settings).run();
config.authToken = 'old-admin-token-123';
config.proxyToken = 'sk-old-proxy-token-123';
config.checkinCron = '0 8 * * *';
config.balanceRefreshCron = '0 * * * *';
config.routingFallbackUnitCost = 1;
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('appends event when runtime settings are updated', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
proxyToken: 'sk-new-proxy-token-456',
checkinCron: '5 9 * * *',
},
});
expect(response.statusCode).toBe(200);
const events = db.select().from(schema.events).all();
expect(events.length).toBe(1);
expect(events[0]).toMatchObject({
type: 'status',
title: '运行时设置已更新',
relatedType: 'settings',
});
expect(events[0].message || '').toContain('代理访问 Token');
expect(events[0].message || '').toContain('签到 Cron');
});
it('rejects proxy token that does not start with sk-', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
proxyToken: 'new-proxy-token-456',
},
});
expect(response.statusCode).toBe(400);
const body = response.json() as { message?: string };
expect(body.message).toContain('sk-');
});
it('persists and returns routing fallback unit cost from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
routingFallbackUnitCost: 0.25,
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { routingFallbackUnitCost?: number };
expect(updated.routingFallbackUnitCost).toBe(0.25);
expect(config.routingFallbackUnitCost).toBe(0.25);
const saved = db.select().from(schema.settings).where(eq(schema.settings.key, 'routing_fallback_unit_cost')).get();
expect(saved).toBeTruthy();
expect(saved?.value).toBe(JSON.stringify(0.25));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/runtime',
});
expect(getResponse.statusCode).toBe(200);
const runtime = getResponse.json() as { routingFallbackUnitCost?: number };
expect(runtime.routingFallbackUnitCost).toBe(0.25);
});
it('appends event when admin auth token changes', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/settings/auth/change',
payload: {
oldToken: 'old-admin-token-123',
newToken: 'new-admin-token-456',
},
});
expect(response.statusCode).toBe(200);
const events = db.select().from(schema.events).all();
expect(events.length).toBe(1);
expect(events[0]).toMatchObject({
type: 'token',
title: '管理员登录令牌已更新',
relatedType: 'settings',
});
});
});
+598
View File
@@ -0,0 +1,598 @@
import { FastifyInstance } from 'fastify';
import cron from 'node-cron';
import { config } from '../../config.js';
import { db, schema } from '../../db/index.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { updateBalanceRefreshCron, updateCheckinCron } from '../../services/checkinScheduler.js';
import { sendNotification } from '../../services/notifyService.js';
import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
type RoutingWeights = typeof config.routingWeights;
interface RuntimeSettingsBody {
proxyToken?: string;
checkinCron?: string;
balanceRefreshCron?: string;
webhookUrl?: string;
barkUrl?: string;
webhookEnabled?: boolean;
barkEnabled?: boolean;
serverChanEnabled?: boolean;
serverChanKey?: string;
smtpEnabled?: boolean;
smtpHost?: string;
smtpPort?: number;
smtpSecure?: boolean;
smtpUser?: string;
smtpPass?: string;
smtpFrom?: string;
smtpTo?: string;
notifyCooldownSec?: number;
adminIpAllowlist?: string[] | string;
routingFallbackUnitCost?: number;
routingWeights?: Partial<RoutingWeights>;
}
const PROXY_TOKEN_PREFIX = 'sk-';
function isValidProxyToken(value: string): boolean {
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
}
function maskSecret(value: string): string {
if (!value) return '';
if (value.length <= 8) return '****';
return `${value.slice(0, 4)}****${value.slice(-4)}`;
}
function upsertSetting(key: string, value: unknown) {
db.insert(schema.settings)
.values({ key, value: JSON.stringify(value) })
.onConflictDoUpdate({
target: schema.settings.key,
set: { value: JSON.stringify(value) },
})
.run();
}
function appendSettingsEvent(input: {
type: 'checkin' | 'balance' | 'proxy' | 'status' | 'token';
title: string;
message: string;
level?: 'info' | 'warning' | 'error';
}) {
try {
db.insert(schema.events).values({
type: input.type,
title: input.title,
message: input.message,
level: input.level || 'info',
relatedType: 'settings',
createdAt: new Date().toISOString(),
}).run();
} catch {}
}
function toPositiveNumberOrFallback(value: unknown, fallback: number) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return fallback;
return n;
}
function toStringList(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0);
}
if (typeof value === 'string') {
return value
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
return [];
}
function applyImportedSettingToRuntime(key: string, value: unknown) {
switch (key) {
case 'checkin_cron': {
if (typeof value !== 'string' || !value || !cron.validate(value)) return;
config.checkinCron = value;
updateCheckinCron(value);
return;
}
case 'balance_refresh_cron': {
if (typeof value !== 'string' || !value || !cron.validate(value)) return;
config.balanceRefreshCron = value;
updateBalanceRefreshCron(value);
return;
}
case 'proxy_token': {
if (typeof value !== 'string') return;
const nextToken = value.trim();
if (!isValidProxyToken(nextToken)) return;
config.proxyToken = nextToken;
return;
}
case 'webhook_url': {
if (typeof value !== 'string') return;
config.webhookUrl = value.trim();
return;
}
case 'webhook_enabled': {
config.webhookEnabled = !!value;
return;
}
case 'bark_url': {
if (typeof value !== 'string') return;
config.barkUrl = value.trim();
return;
}
case 'bark_enabled': {
config.barkEnabled = !!value;
return;
}
case 'serverchan_enabled': {
config.serverChanEnabled = !!value;
return;
}
case 'serverchan_key': {
if (typeof value !== 'string') return;
config.serverChanKey = value.trim();
return;
}
case 'smtp_enabled': {
config.smtpEnabled = !!value;
return;
}
case 'smtp_host': {
if (typeof value !== 'string') return;
config.smtpHost = value.trim();
return;
}
case 'smtp_port': {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return;
config.smtpPort = Math.trunc(n);
return;
}
case 'smtp_secure': {
config.smtpSecure = !!value;
return;
}
case 'smtp_user': {
if (typeof value !== 'string') return;
config.smtpUser = value.trim();
return;
}
case 'smtp_pass': {
if (typeof value !== 'string') return;
config.smtpPass = value.trim();
return;
}
case 'smtp_from': {
if (typeof value !== 'string') return;
config.smtpFrom = value.trim();
return;
}
case 'smtp_to': {
if (typeof value !== 'string') return;
config.smtpTo = value.trim();
return;
}
case 'notify_cooldown_sec': {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return;
config.notifyCooldownSec = Math.trunc(n);
return;
}
case 'admin_ip_allowlist': {
config.adminIpAllowlist = toStringList(value);
return;
}
case 'routing_weights': {
if (!value || typeof value !== 'object') return;
const rw = value as Partial<RoutingWeights>;
config.routingWeights = {
baseWeightFactor: toPositiveNumberOrFallback(rw.baseWeightFactor, config.routingWeights.baseWeightFactor),
valueScoreFactor: toPositiveNumberOrFallback(rw.valueScoreFactor, config.routingWeights.valueScoreFactor),
costWeight: toPositiveNumberOrFallback(rw.costWeight, config.routingWeights.costWeight),
balanceWeight: toPositiveNumberOrFallback(rw.balanceWeight, config.routingWeights.balanceWeight),
usageWeight: toPositiveNumberOrFallback(rw.usageWeight, config.routingWeights.usageWeight),
};
return;
}
case 'routing_fallback_unit_cost': {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return;
config.routingFallbackUnitCost = Math.max(1e-6, n);
return;
}
default:
return;
}
}
function getRuntimeSettingsResponse() {
return {
checkinCron: config.checkinCron,
balanceRefreshCron: config.balanceRefreshCron,
routingFallbackUnitCost: config.routingFallbackUnitCost,
routingWeights: config.routingWeights,
webhookUrl: config.webhookUrl,
barkUrl: config.barkUrl,
webhookEnabled: config.webhookEnabled,
barkEnabled: config.barkEnabled,
serverChanEnabled: config.serverChanEnabled,
serverChanKeyMasked: maskSecret(config.serverChanKey),
smtpEnabled: config.smtpEnabled,
smtpHost: config.smtpHost,
smtpPort: config.smtpPort,
smtpSecure: config.smtpSecure,
smtpUser: config.smtpUser,
smtpPassMasked: maskSecret(config.smtpPass),
smtpFrom: config.smtpFrom,
smtpTo: config.smtpTo,
notifyCooldownSec: config.notifyCooldownSec,
adminIpAllowlist: config.adminIpAllowlist,
proxyTokenMasked: maskSecret(config.proxyToken),
};
}
export async function settingsRoutes(app: FastifyInstance) {
app.get('/api/settings/runtime', async () => {
return getRuntimeSettingsResponse();
});
app.put<{ Body: RuntimeSettingsBody }>('/api/settings/runtime', async (request, reply) => {
const body = request.body || {};
const changedLabels: string[] = [];
if (body.checkinCron !== undefined) {
if (!cron.validate(body.checkinCron)) {
return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' });
}
if (body.checkinCron !== config.checkinCron) {
changedLabels.push(`签到 Cron(${config.checkinCron} -> ${body.checkinCron})`);
}
updateCheckinCron(body.checkinCron);
upsertSetting('checkin_cron', body.checkinCron);
}
if (body.balanceRefreshCron !== undefined) {
if (!cron.validate(body.balanceRefreshCron)) {
return reply.code(400).send({ success: false, message: '余额刷新 Cron 表达式无效' });
}
if (body.balanceRefreshCron !== config.balanceRefreshCron) {
changedLabels.push(`余额刷新 Cron(${config.balanceRefreshCron} -> ${body.balanceRefreshCron})`);
}
updateBalanceRefreshCron(body.balanceRefreshCron);
upsertSetting('balance_refresh_cron', body.balanceRefreshCron);
}
if (body.proxyToken !== undefined) {
const proxyToken = String(body.proxyToken).trim();
if (!proxyToken.startsWith(PROXY_TOKEN_PREFIX)) {
return reply.code(400).send({ success: false, message: '下游访问令牌必须以 sk- 开头' });
}
if (proxyToken.length < 6) {
return reply.code(400).send({ success: false, message: '下游访问令牌至少 6 位(含 sk-)' });
}
if (proxyToken !== config.proxyToken) {
changedLabels.push('代理访问 Token');
}
config.proxyToken = proxyToken;
upsertSetting('proxy_token', proxyToken);
}
if (body.webhookUrl !== undefined) {
if (String(body.webhookUrl || '').trim() !== config.webhookUrl) {
changedLabels.push('Webhook 地址');
}
config.webhookUrl = String(body.webhookUrl || '').trim();
upsertSetting('webhook_url', config.webhookUrl);
}
if (body.webhookEnabled !== undefined) {
if (!!body.webhookEnabled !== config.webhookEnabled) {
changedLabels.push('Webhook 开关');
}
config.webhookEnabled = !!body.webhookEnabled;
upsertSetting('webhook_enabled', config.webhookEnabled);
}
if (body.barkUrl !== undefined) {
if (String(body.barkUrl || '').trim() !== config.barkUrl) {
changedLabels.push('Bark 地址');
}
config.barkUrl = String(body.barkUrl || '').trim();
upsertSetting('bark_url', config.barkUrl);
}
if (body.barkEnabled !== undefined) {
if (!!body.barkEnabled !== config.barkEnabled) {
changedLabels.push('Bark 开关');
}
config.barkEnabled = !!body.barkEnabled;
upsertSetting('bark_enabled', config.barkEnabled);
}
if (body.serverChanEnabled !== undefined) {
if (!!body.serverChanEnabled !== config.serverChanEnabled) {
changedLabels.push('Server 酱开关');
}
config.serverChanEnabled = !!body.serverChanEnabled;
upsertSetting('serverchan_enabled', config.serverChanEnabled);
}
if (body.serverChanKey !== undefined) {
if (String(body.serverChanKey || '').trim() !== config.serverChanKey) {
changedLabels.push('Server 酱密钥');
}
config.serverChanKey = String(body.serverChanKey || '').trim();
upsertSetting('serverchan_key', config.serverChanKey);
}
if (body.smtpEnabled !== undefined) {
if (!!body.smtpEnabled !== config.smtpEnabled) {
changedLabels.push('SMTP 开关');
}
config.smtpEnabled = !!body.smtpEnabled;
upsertSetting('smtp_enabled', config.smtpEnabled);
}
if (body.smtpHost !== undefined) {
if (String(body.smtpHost || '').trim() !== config.smtpHost) {
changedLabels.push('SMTP 主机');
}
config.smtpHost = String(body.smtpHost || '').trim();
upsertSetting('smtp_host', config.smtpHost);
}
if (body.smtpPort !== undefined) {
const smtpPort = Number(body.smtpPort);
if (!Number.isFinite(smtpPort) || smtpPort <= 0) {
return reply.code(400).send({ success: false, message: 'SMTP 端口无效' });
}
if (Math.trunc(smtpPort) !== config.smtpPort) {
changedLabels.push(`SMTP 端口(${config.smtpPort} -> ${Math.trunc(smtpPort)})`);
}
config.smtpPort = Math.trunc(smtpPort);
upsertSetting('smtp_port', config.smtpPort);
}
if (body.smtpSecure !== undefined) {
if (!!body.smtpSecure !== config.smtpSecure) {
changedLabels.push('SMTP 安全连接');
}
config.smtpSecure = !!body.smtpSecure;
upsertSetting('smtp_secure', config.smtpSecure);
}
if (body.smtpUser !== undefined) {
if (String(body.smtpUser || '').trim() !== config.smtpUser) {
changedLabels.push('SMTP 用户');
}
config.smtpUser = String(body.smtpUser || '').trim();
upsertSetting('smtp_user', config.smtpUser);
}
if (body.smtpPass !== undefined) {
if (String(body.smtpPass || '').trim() !== config.smtpPass) {
changedLabels.push('SMTP 密码');
}
config.smtpPass = String(body.smtpPass || '').trim();
upsertSetting('smtp_pass', config.smtpPass);
}
if (body.smtpFrom !== undefined) {
if (String(body.smtpFrom || '').trim() !== config.smtpFrom) {
changedLabels.push('发件人地址');
}
config.smtpFrom = String(body.smtpFrom || '').trim();
upsertSetting('smtp_from', config.smtpFrom);
}
if (body.smtpTo !== undefined) {
if (String(body.smtpTo || '').trim() !== config.smtpTo) {
changedLabels.push('收件人地址');
}
config.smtpTo = String(body.smtpTo || '').trim();
upsertSetting('smtp_to', config.smtpTo);
}
if (body.notifyCooldownSec !== undefined) {
const notifyCooldownSec = Number(body.notifyCooldownSec);
if (!Number.isFinite(notifyCooldownSec) || notifyCooldownSec < 0) {
return reply.code(400).send({ success: false, message: '告警冷静期必须是大于等于 0 的数字(秒)' });
}
const nextCooldown = Math.trunc(notifyCooldownSec);
if (nextCooldown !== config.notifyCooldownSec) {
changedLabels.push(`告警冷静期(${config.notifyCooldownSec}s -> ${nextCooldown}s)`);
}
config.notifyCooldownSec = nextCooldown;
upsertSetting('notify_cooldown_sec', config.notifyCooldownSec);
}
if (body.adminIpAllowlist !== undefined) {
const nextAllowlist = toStringList(body.adminIpAllowlist);
if (JSON.stringify(nextAllowlist) !== JSON.stringify(config.adminIpAllowlist)) {
changedLabels.push('管理端 IP 白名单');
}
config.adminIpAllowlist = nextAllowlist;
upsertSetting('admin_ip_allowlist', nextAllowlist);
}
if (body.routingWeights !== undefined) {
const nextWeights: RoutingWeights = {
baseWeightFactor: toPositiveNumberOrFallback(body.routingWeights.baseWeightFactor, config.routingWeights.baseWeightFactor),
valueScoreFactor: toPositiveNumberOrFallback(body.routingWeights.valueScoreFactor, config.routingWeights.valueScoreFactor),
costWeight: toPositiveNumberOrFallback(body.routingWeights.costWeight, config.routingWeights.costWeight),
balanceWeight: toPositiveNumberOrFallback(body.routingWeights.balanceWeight, config.routingWeights.balanceWeight),
usageWeight: toPositiveNumberOrFallback(body.routingWeights.usageWeight, config.routingWeights.usageWeight),
};
if (JSON.stringify(nextWeights) !== JSON.stringify(config.routingWeights)) {
changedLabels.push('路由权重');
}
config.routingWeights = nextWeights;
upsertSetting('routing_weights', nextWeights);
}
if (body.routingFallbackUnitCost !== undefined) {
const nextRoutingFallbackUnitCost = Number(body.routingFallbackUnitCost);
if (!Number.isFinite(nextRoutingFallbackUnitCost) || nextRoutingFallbackUnitCost <= 0) {
return reply.code(400).send({ success: false, message: '无价模型默认单价必须是大于 0 的数字' });
}
const normalized = Math.max(1e-6, nextRoutingFallbackUnitCost);
if (Math.abs(normalized - config.routingFallbackUnitCost) > 1e-12) {
changedLabels.push(`无价模型默认单价(${config.routingFallbackUnitCost} -> ${normalized})`);
}
config.routingFallbackUnitCost = normalized;
upsertSetting('routing_fallback_unit_cost', normalized);
}
if (changedLabels.length > 0) {
let eventType: 'checkin' | 'balance' | 'proxy' | 'status' | 'token' = 'status';
if (changedLabels.length === 1) {
if (changedLabels[0].startsWith('签到 Cron')) eventType = 'checkin';
else if (changedLabels[0].startsWith('余额刷新 Cron')) eventType = 'balance';
else if (changedLabels[0] === '代理访问 Token') eventType = 'proxy';
}
appendSettingsEvent({
type: eventType,
title: '运行时设置已更新',
message: `已更新:${changedLabels.join('、')}`,
});
}
return {
success: true,
message: '运行时设置已更新',
...getRuntimeSettingsResponse(),
};
});
app.get<{ Querystring: { type?: string } }>('/api/settings/backup/export', async (request, reply) => {
const rawType = String(request.query.type || 'all').trim().toLowerCase();
const type: BackupExportType = rawType === 'accounts' || rawType === 'preferences' ? rawType : 'all';
if (rawType && !['all', 'accounts', 'preferences'].includes(rawType)) {
return reply.code(400).send({ success: false, message: '导出类型无效,仅支持 all/accounts/preferences' });
}
return exportBackup(type);
});
app.post<{ Body: { data?: Record<string, unknown> } }>('/api/settings/backup/import', async (request, reply) => {
const payload = request.body?.data;
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return reply.code(400).send({ success: false, message: '导入数据格式错误:需要 JSON 对象' });
}
try {
const result = importBackup(payload);
for (const item of result.appliedSettings) {
applyImportedSettingToRuntime(item.key, item.value);
}
return {
success: true,
message: '导入完成',
...result,
};
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || '导入失败',
});
}
});
app.post('/api/settings/notify/test', async (_, reply) => {
try {
const result = await sendNotification(
'测试通知',
'您好,这是一条来自系统设置的连通性测试通知,您的通知相关配置目前工作正常!',
'info',
{
bypassThrottle: true,
requireChannel: true,
throwOnFailure: true,
},
);
return {
success: true,
message: `测试通知已发送(成功 ${result.succeeded}/${result.attempted})`,
};
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || '测试通知发送失败',
});
}
});
app.post('/api/settings/maintenance/clear-cache', async (_, reply) => {
const deletedModelAvailability = db.delete(schema.modelAvailability).run().changes;
const deletedRouteChannels = db.delete(schema.routeChannels).run().changes;
const deletedTokenRoutes = db.delete(schema.tokenRoutes).run().changes;
const { task, reused } = startBackgroundTask(
{
type: 'maintenance',
title: '清理缓存并重建路由',
dedupeKey: 'refresh-models-and-rebuild-routes',
notifyOnFailure: true,
successMessage: (currentTask) => {
const rebuild = (currentTask.result as any)?.rebuild;
if (!rebuild) return '缓存清理后重建路由已完成';
return `缓存清理后重建完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}`;
},
failureMessage: (currentTask) => `缓存清理后重建失败:${currentTask.error || 'unknown error'}`,
},
async () => refreshModelsAndRebuildRoutes(),
);
return reply.code(202).send({
success: true,
queued: true,
reused,
jobId: task.id,
message: '缓存已清理,重建路由已开始执行',
deletedModelAvailability,
deletedRouteChannels,
deletedTokenRoutes,
});
});
app.post('/api/settings/maintenance/clear-usage', async () => {
const deletedProxyLogs = db.delete(schema.proxyLogs).run().changes;
db.update(schema.routeChannels).set({
successCount: 0,
failCount: 0,
totalLatencyMs: 0,
totalCost: 0,
lastUsedAt: null,
lastFailAt: null,
cooldownUntil: null,
}).run();
db.update(schema.accounts).set({
balanceUsed: 0,
updatedAt: new Date().toISOString(),
}).run();
appendSettingsEvent({
type: 'status',
title: '占用统计与使用日志已清理',
message: `已清理使用日志 ${deletedProxyLogs} 条,并重置路由与账号占用统计`,
level: 'warning',
});
return {
success: true,
message: '占用统计已清理',
deletedProxyLogs,
};
});
}
@@ -0,0 +1,74 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../../db/index.js');
describe('sites status cascade', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-status-cascade-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./sites.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.sitesRoutes);
});
beforeEach(() => {
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('disables and re-enables related accounts with site status', async () => {
const site = db.insert(schema.sites).values({
name: 'status-site',
url: 'https://status-site.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'status-user',
accessToken: 'access-token',
status: 'active',
}).returning().get();
const disableResp = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}`,
payload: { status: 'disabled' },
});
expect(disableResp.statusCode).toBe(200);
const disabledAccount = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
expect(disabledAccount?.status).toBe('disabled');
const enableResp = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}`,
payload: { status: 'active' },
});
expect(enableResp.statusCode).toBe(200);
const enabledAccount = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
expect(enabledAccount?.status).toBe('active');
});
});
+125
View File
@@ -0,0 +1,125 @@
import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, eq } from 'drizzle-orm';
import { detectSite } from '../../services/siteDetector.js';
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
if (input === undefined || input === null) return null;
if (typeof input !== 'string') return null;
const status = input.trim().toLowerCase();
if (status === 'active' || status === 'disabled') return status;
return null;
}
export async function sitesRoutes(app: FastifyInstance) {
// List all sites
app.get('/api/sites', async () => {
return db.select().from(schema.sites).all();
});
// Add a site
app.post<{ Body: { name: string; url: string; platform?: string; apiKey?: string; status?: string } }>('/api/sites', async (request, reply) => {
const { name, url, platform, apiKey, status } = request.body;
const normalizedStatus = normalizeSiteStatus(status);
if (status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
}
let detectedPlatform = platform;
if (!detectedPlatform) {
const detected = await detectSite(url);
detectedPlatform = detected?.platform;
}
if (!detectedPlatform) {
return { error: 'Could not detect platform. Please specify manually.' };
}
const result = db.insert(schema.sites).values({
name,
url: url.replace(/\/+$/, ''),
platform: detectedPlatform,
apiKey,
status: normalizedStatus ?? 'active',
}).returning().get();
return result;
});
// Update a site
app.put<{ Params: { id: string }; Body: { name?: string; url?: string; platform?: string; apiKey?: string; status?: string } }>('/api/sites/:id', async (request, reply) => {
const id = parseInt(request.params.id);
if (Number.isNaN(id)) {
return reply.code(400).send({ error: 'Invalid site id' });
}
const existingSite = db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
if (!existingSite) {
return reply.code(404).send({ error: 'Site not found' });
}
const updates: any = {};
const body = request.body;
const normalizedStatus = normalizeSiteStatus(body.status);
if (body.status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
}
if (body.name !== undefined) updates.name = body.name;
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
if (body.platform !== undefined) updates.platform = body.platform;
if (body.apiKey !== undefined) updates.apiKey = body.apiKey;
if (body.status !== undefined) updates.status = normalizedStatus;
updates.updatedAt = new Date().toISOString();
db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
if (body.status !== undefined && normalizedStatus) {
const now = new Date().toISOString();
if (normalizedStatus === 'disabled') {
db.update(schema.accounts)
.set({ status: 'disabled', updatedAt: now })
.where(eq(schema.accounts.siteId, id))
.run();
try {
db.insert(schema.events).values({
type: 'status',
title: '站点已禁用',
message: `${existingSite.name} 已禁用,关联账号已全部置为禁用`,
level: 'warning',
relatedId: id,
relatedType: 'site',
}).run();
} catch {}
} else {
db.update(schema.accounts)
.set({ status: 'active', updatedAt: now })
.where(and(eq(schema.accounts.siteId, id), eq(schema.accounts.status, 'disabled')))
.run();
try {
db.insert(schema.events).values({
type: 'status',
title: '站点已启用',
message: `${existingSite.name} 已启用,关联禁用账号已恢复为活跃`,
level: 'info',
relatedId: id,
relatedType: 'site',
}).run();
} catch {}
}
}
return db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
});
// Delete a site
app.delete<{ Params: { id: string } }>('/api/sites/:id', async (request) => {
const id = parseInt(request.params.id);
db.delete(schema.sites).where(eq(schema.sites.id, id)).run();
return { success: true };
});
// Detect platform for a URL
app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => {
const result = await detectSite(request.body.url);
return result || { error: 'Could not detect platform' };
});
}
@@ -0,0 +1,156 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
import { sql } from 'drizzle-orm';
import { formatLocalDate } from '../../services/localTimeService.js';
type DbModule = typeof import('../../db/index.js');
describe('stats dashboard filters disabled sites', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-stats-site-status-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./stats.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.statsRoutes);
});
beforeEach(() => {
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('excludes disabled-site balances from dashboard totals', async () => {
const activeSite = db.insert(schema.sites).values({
name: 'active-site',
url: 'https://active-site.example.com',
platform: 'new-api',
}).returning().get();
const disabledSite = db.insert(schema.sites).values({
name: 'disabled-site',
url: 'https://disabled-site.example.com',
platform: 'new-api',
}).returning().get();
db.run(sql`update sites set status = 'disabled' where id = ${disabledSite.id}`);
db.insert(schema.accounts).values({
siteId: activeSite.id,
username: 'active-user',
accessToken: 'active-token',
balance: 100,
status: 'active',
}).run();
db.insert(schema.accounts).values({
siteId: disabledSite.id,
username: 'disabled-user',
accessToken: 'disabled-token',
balance: 900,
status: 'active',
}).run();
const response = await app.inject({
method: 'GET',
url: '/api/stats/dashboard',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
totalBalance: number;
activeAccounts: number;
totalAccounts: number;
};
expect(body.totalBalance).toBe(100);
expect(body.activeAccounts).toBe(1);
expect(body.totalAccounts).toBe(1);
});
it('treats skipped checkins as successful in dashboard stats', async () => {
const today = formatLocalDate(new Date());
const site = db.insert(schema.sites).values({
name: 'checkin-site',
url: 'https://checkin-site.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'checkin-user',
accessToken: 'token',
balance: 10,
status: 'active',
}).returning().get();
db.insert(schema.checkinLogs).values([
{
accountId: account.id,
status: 'success',
message: 'checked in',
reward: '1',
createdAt: `${today} 09:00:00`,
},
{
accountId: account.id,
status: 'skipped',
message: 'today already checked in',
reward: '',
createdAt: `${today} 09:10:00`,
},
{
accountId: account.id,
status: 'failed',
message: 'checkin failed',
reward: '',
createdAt: `${today} 09:20:00`,
},
]).run();
const response = await app.inject({
method: 'GET',
url: '/api/stats/dashboard',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
todayCheckin: {
success: number;
failed: number;
total: number;
};
};
expect(body.todayCheckin).toEqual({
success: 2,
failed: 1,
total: 3,
});
});
});
@@ -0,0 +1,142 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
import {
formatLocalDate,
formatUtcSqlDateTime,
getLocalDayRangeUtc,
parseStoredUtcDateTime,
} from '../../services/localTimeService.js';
type DbModule = typeof import('../../db/index.js');
describe('stats dashboard today reward fallback', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-stats-reward-fallback-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./stats.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.statsRoutes);
});
beforeEach(() => {
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('uses today income value for dashboard todayReward when reward text is empty', async () => {
const today = formatLocalDate(new Date());
const site = db.insert(schema.sites).values({
name: 'stats-site',
url: 'https://stats-site.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'stats-user',
accessToken: 'token',
status: 'active',
extraConfig: JSON.stringify({
todayIncomeSnapshot: {
day: today,
baseline: 4.8,
latest: 4.8,
updatedAt: `${today}T10:00:00.000Z`,
},
}),
}).returning().get();
db.insert(schema.checkinLogs).values({
accountId: account.id,
status: 'success',
message: 'checked in',
reward: '',
createdAt: `${today} 10:01:00`,
}).run();
const response = await app.inject({
method: 'GET',
url: '/api/stats/dashboard',
});
expect(response.statusCode).toBe(200);
const body = response.json() as { todayReward: number };
expect(body.todayReward).toBe(4.8);
});
it('counts dashboard today spend only inside local-day range', async () => {
const site = db.insert(schema.sites).values({
name: 'stats-spend-site',
url: 'https://stats-spend.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'stats-spend-user',
accessToken: 'token',
status: 'active',
}).returning().get();
const { startUtc, endUtc } = getLocalDayRangeUtc();
const startDate = parseStoredUtcDateTime(startUtc)!;
const endDate = parseStoredUtcDateTime(endUtc)!;
const beforeStart = formatUtcSqlDateTime(new Date(startDate.getTime() - 60_000));
const inRange = formatUtcSqlDateTime(new Date(startDate.getTime() + 60_000));
const afterEnd = formatUtcSqlDateTime(new Date(endDate.getTime() + 60_000));
db.insert(schema.proxyLogs).values([
{
accountId: account.id,
status: 'success',
estimatedCost: 1,
createdAt: beforeStart,
},
{
accountId: account.id,
status: 'success',
estimatedCost: 3,
createdAt: inRange,
},
{
accountId: account.id,
status: 'success',
estimatedCost: 5,
createdAt: afterEnd,
},
]).run();
const response = await app.inject({
method: 'GET',
url: '/api/stats/dashboard',
});
expect(response.statusCode).toBe(200);
const body = response.json() as { todaySpend: number };
expect(body.todaySpend).toBe(3);
});
});
+637
View File
@@ -0,0 +1,637 @@
import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, desc, gte, eq, lt } from 'drizzle-orm';
import {
refreshModelsForAccount,
refreshModelsAndRebuildRoutes,
rebuildTokenRoutesFromAvailability,
} from '../../services/modelService.js';
import { buildModelAnalysis } from '../../services/modelAnalysisService.js';
import { fallbackTokenCost, fetchModelPricingCatalog } from '../../services/modelPricingService.js';
import { getUpstreamModelDescriptionsCached } from '../../services/upstreamModelDescriptionService.js';
import { getRunningTaskByDedupeKey, startBackgroundTask } from '../../services/backgroundTaskService.js';
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js';
import {
getLocalDayRangeUtc,
getLocalRangeStartUtc,
parseStoredUtcDateTime,
toLocalDayKeyFromStoredUtc,
} from '../../services/localTimeService.js';
function parseBooleanFlag(raw?: string): boolean {
if (!raw) return false;
const normalized = raw.trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
type ModelsMarketplaceCacheEntry = {
expiresAt: number;
models: any[];
};
const modelsMarketplaceCache = new Map<'base' | 'pricing', ModelsMarketplaceCacheEntry>();
function readModelsMarketplaceCache(includePricing: boolean): any[] | null {
const key = includePricing ? 'pricing' : 'base';
const cached = modelsMarketplaceCache.get(key);
if (!cached) return null;
if (Date.now() >= cached.expiresAt) {
modelsMarketplaceCache.delete(key);
return null;
}
return cached.models;
}
function writeModelsMarketplaceCache(includePricing: boolean, models: any[]): void {
const ttl = includePricing ? MODELS_MARKETPLACE_PRICING_TTL_MS : MODELS_MARKETPLACE_BASE_TTL_MS;
const key = includePricing ? 'pricing' : 'base';
modelsMarketplaceCache.set(key, {
expiresAt: Date.now() + ttl,
models,
});
}
export async function statsRoutes(app: FastifyInstance) {
// Dashboard summary
app.get('/api/stats/dashboard', async () => {
const accountRows = db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.sites.status, 'active'))
.all();
const accounts = accountRows.map((row) => row.accounts);
const totalBalance = accounts.reduce((sum, a) => sum + (a.balance || 0), 0);
const activeCount = accounts.filter((a) => a.status === 'active').length;
const { localDay: today, startUtc: todayStartUtc, endUtc: todayEndUtc } = getLocalDayRangeUtc();
const todayCheckinRows = db.select().from(schema.checkinLogs)
.innerJoin(schema.accounts, eq(schema.checkinLogs.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(
gte(schema.checkinLogs.createdAt, todayStartUtc),
lt(schema.checkinLogs.createdAt, todayEndUtc),
eq(schema.sites.status, 'active'),
))
.all();
const todayCheckins = todayCheckinRows.map((row) => row.checkin_logs);
const checkinFailed = todayCheckins.filter((c) => c.status === 'failed').length;
const checkinSuccess = todayCheckins.length - checkinFailed;
const rewardByAccount: Record<number, number> = {};
const successCountByAccount: Record<number, number> = {};
const parsedRewardCountByAccount: Record<number, number> = {};
for (const row of todayCheckinRows) {
const checkin = row.checkin_logs;
if (checkin.status !== 'success') continue;
const accountId = row.accounts.id;
successCountByAccount[accountId] = (successCountByAccount[accountId] || 0) + 1;
const rewardValue = parseCheckinRewardAmount(checkin.reward) || parseCheckinRewardAmount(checkin.message);
if (rewardValue <= 0) continue;
rewardByAccount[accountId] = (rewardByAccount[accountId] || 0) + rewardValue;
parsedRewardCountByAccount[accountId] = (parsedRewardCountByAccount[accountId] || 0) + 1;
}
const nowTs = Date.now();
const last24hTs = nowTs - 86400000;
const last7dDate = getLocalRangeStartUtc(7);
const recentProxyLogs = db.select().from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(gte(schema.proxyLogs.createdAt, last7dDate), eq(schema.sites.status, 'active')))
.all()
.map((row) => row.proxy_logs);
const allProxyLogs = db.select()
.from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.sites.status, 'active'))
.all();
const proxy24hLogs = recentProxyLogs.filter((log) => {
if (!log.createdAt) return false;
const ts = parseStoredUtcDateTime(log.createdAt)?.getTime() ?? Number.NaN;
return Number.isFinite(ts) && ts >= last24hTs;
});
const proxySuccess = proxy24hLogs.filter((l) => l.status === 'success').length;
const proxyFailed = proxy24hLogs.filter((l) => l.status === 'failed').length;
const totalTokens = proxy24hLogs.reduce((sum, l) => sum + (l.totalTokens || 0), 0);
const totalUsed = allProxyLogs.reduce((sum, row) => {
const log = row.proxy_logs;
const platform = row.sites?.platform || 'new-api';
const explicitCost = typeof log.estimatedCost === 'number' ? log.estimatedCost : 0;
if (explicitCost > 0) return sum + explicitCost;
return sum + fallbackTokenCost(log.totalTokens || 0, platform);
}, 0);
const todayProxyLogs = recentProxyLogs.filter((log) => {
if (!log.createdAt) return false;
return log.createdAt >= todayStartUtc && log.createdAt < todayEndUtc;
});
const todaySpend = todayProxyLogs.reduce((sum, log) => {
const cost = typeof log.estimatedCost === 'number' ? log.estimatedCost : 0;
return sum + cost;
}, 0);
const todayReward = accounts.reduce((sum, account) => sum + estimateRewardWithTodayIncomeFallback({
day: today,
successCount: successCountByAccount[account.id] || 0,
parsedRewardCount: parsedRewardCountByAccount[account.id] || 0,
rewardSum: rewardByAccount[account.id] || 0,
extraConfig: account.extraConfig,
}), 0);
const modelAnalysis = buildModelAnalysis(recentProxyLogs, { days: 7 });
return {
totalBalance,
totalUsed: Math.round(totalUsed * 1_000_000) / 1_000_000,
todaySpend: Math.round(todaySpend * 1_000_000) / 1_000_000,
todayReward: Math.round(todayReward * 1_000_000) / 1_000_000,
activeAccounts: activeCount,
totalAccounts: accounts.length,
todayCheckin: { success: checkinSuccess, failed: checkinFailed, total: todayCheckins.length },
proxy24h: { success: proxySuccess, failed: proxyFailed, total: proxy24hLogs.length, totalTokens },
modelAnalysis,
};
});
// Proxy logs
app.get<{ Querystring: { limit?: string; offset?: string } }>('/api/stats/proxy-logs', async (request) => {
const limit = parseInt(request.query.limit || '50', 10);
const offset = parseInt(request.query.offset || '0', 10);
const rows = db.select().from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.orderBy(desc(schema.proxyLogs.createdAt))
.limit(limit).offset(offset).all();
return rows.map((row) => ({
...row.proxy_logs,
username: row.accounts?.username || null,
siteName: row.sites?.name || null,
siteUrl: row.sites?.url || null,
}));
});
// Models marketplace - refresh upstream models and aggregate.
app.get<{ Querystring: { refresh?: string; includePricing?: string } }>('/api/models/marketplace', async (request) => {
const refreshRequested = parseBooleanFlag(request.query.refresh);
const includePricing = parseBooleanFlag(request.query.includePricing);
let refreshQueued = false;
let refreshReused = false;
let refreshJobId: string | null = null;
if (refreshRequested) {
modelsMarketplaceCache.clear();
const { task, reused } = startBackgroundTask(
{
type: 'model',
title: '刷新模型广场数据',
dedupeKey: 'refresh-models-and-rebuild-routes',
notifyOnFailure: true,
successMessage: (currentTask) => {
const rebuild = (currentTask.result as any)?.rebuild;
if (!rebuild) return '模型广场刷新已完成';
return `模型广场刷新完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}`;
},
failureMessage: (currentTask) => `模型广场刷新失败:${currentTask.error || 'unknown error'}`,
},
async () => refreshModelsAndRebuildRoutes(),
);
refreshQueued = !reused;
refreshReused = reused;
refreshJobId = task.id;
}
const runningRefreshTask = getRunningTaskByDedupeKey('refresh-models-and-rebuild-routes');
if (!refreshJobId && runningRefreshTask) refreshJobId = runningRefreshTask.id;
if (!refreshRequested) {
const cachedModels = readModelsMarketplaceCache(includePricing);
if (cachedModels) {
return {
models: cachedModels,
meta: {
refreshRequested,
refreshQueued,
refreshReused,
refreshRunning: !!runningRefreshTask,
refreshJobId,
includePricing,
cacheHit: true,
},
};
}
}
const availability = db.select().from(schema.tokenModelAvailability)
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.all();
const last7d = getLocalRangeStartUtc(7);
const recentLogs = db.select().from(schema.proxyLogs)
.where(gte(schema.proxyLogs.createdAt, last7d))
.all();
const modelLogStats: Record<string, { success: number; total: number; totalLatency: number }> = {};
for (const log of recentLogs) {
const model = log.modelActual || log.modelRequested || '';
if (!modelLogStats[model]) modelLogStats[model] = { success: 0, total: 0, totalLatency: 0 };
modelLogStats[model].total++;
if (log.status === 'success') modelLogStats[model].success++;
modelLogStats[model].totalLatency += log.latencyMs || 0;
}
type ModelMetadataAggregate = {
description: string | null;
tags: Set<string>;
supportedEndpointTypes: Set<string>;
pricingSources: Array<{
siteId: number;
siteName: string;
accountId: number;
username: string | null;
ownerBy: string | null;
enableGroups: string[];
groupPricing: Record<string, {
quotaType: number;
inputPerMillion?: number;
outputPerMillion?: number;
perCallInput?: number;
perCallOutput?: number;
perCallTotal?: number;
}>;
}>;
};
const modelMetadataMap = new Map<string, ModelMetadataAggregate>();
if (includePricing) {
const activeAccountRows = db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(eq(schema.accounts.status, 'active'), eq(schema.sites.status, 'active')))
.all();
const metadataResults = await Promise.all(activeAccountRows.map(async (row) => {
const catalog = await fetchModelPricingCatalog({
site: {
id: row.sites.id,
url: row.sites.url,
platform: row.sites.platform,
apiKey: row.sites.apiKey,
},
account: {
id: row.accounts.id,
accessToken: row.accounts.accessToken,
apiToken: row.accounts.apiToken,
},
modelName: '__metadata__',
totalTokens: 0,
});
return {
account: row.accounts,
site: row.sites,
catalog,
};
}));
for (const result of metadataResults) {
if (!result.catalog) continue;
for (const model of result.catalog.models) {
const key = model.modelName.toLowerCase();
if (!modelMetadataMap.has(key)) {
modelMetadataMap.set(key, {
description: null,
tags: new Set<string>(),
supportedEndpointTypes: new Set<string>(),
pricingSources: [],
});
}
const aggregate = modelMetadataMap.get(key)!;
if (!aggregate.description && model.modelDescription) {
aggregate.description = model.modelDescription;
}
for (const tag of model.tags) aggregate.tags.add(tag);
for (const endpointType of model.supportedEndpointTypes) {
aggregate.supportedEndpointTypes.add(endpointType);
}
aggregate.pricingSources.push({
siteId: result.site.id,
siteName: result.site.name,
accountId: result.account.id,
username: result.account.username,
ownerBy: model.ownerBy,
enableGroups: model.enableGroups,
groupPricing: model.groupPricing,
});
}
}
}
const modelMap: Record<string, {
name: string;
accountsById: Map<number, {
id: number;
site: string;
username: string | null;
latency: number | null;
unitCost: number | null;
balance: number;
tokens: Array<{ id: number; name: string; isDefault: boolean }>;
}>;
}> = {};
for (const row of availability) {
const m = row.token_model_availability;
const t = row.account_tokens;
const a = row.accounts;
const s = row.sites;
if (!m.available || !t.enabled || a.status !== 'active' || s.status !== 'active') continue;
if (!modelMap[m.modelName]) {
modelMap[m.modelName] = { name: m.modelName, accountsById: new Map() };
}
const existingAccount = modelMap[m.modelName].accountsById.get(a.id);
if (!existingAccount) {
modelMap[m.modelName].accountsById.set(a.id, {
id: a.id,
site: s.name,
username: a.username,
latency: m.latencyMs,
unitCost: a.unitCost,
balance: a.balance || 0,
tokens: [{ id: t.id, name: t.name, isDefault: !!t.isDefault }],
});
} else {
const nextLatency = (() => {
if (existingAccount.latency == null) return m.latencyMs;
if (m.latencyMs == null) return existingAccount.latency;
return Math.min(existingAccount.latency, m.latencyMs);
})();
existingAccount.latency = nextLatency;
if (!existingAccount.tokens.some((token) => token.id === t.id)) {
existingAccount.tokens.push({ id: t.id, name: t.name, isDefault: !!t.isDefault });
}
}
}
let upstreamDescriptionMap = new Map<string, string>();
if (includePricing) {
const hasMissingDescription = Object.keys(modelMap).some((modelName) => {
const metadata = modelMetadataMap.get(modelName.toLowerCase());
return !metadata?.description;
});
if (hasMissingDescription) {
upstreamDescriptionMap = await getUpstreamModelDescriptionsCached();
}
}
const models = Object.values(modelMap).map((m) => {
const logStats = modelLogStats[m.name];
const accounts = Array.from(m.accountsById.values());
const avgLatency = accounts.reduce((sum, a) => sum + (a.latency || 0), 0) / (accounts.length || 1);
const metadata = modelMetadataMap.get(m.name.toLowerCase());
const fallbackDescription = metadata?.description ? null : upstreamDescriptionMap.get(m.name.toLowerCase()) || null;
return {
name: m.name,
accountCount: accounts.length,
tokenCount: accounts.reduce((sum, account) => sum + account.tokens.length, 0),
avgLatency: Math.round(avgLatency),
successRate: logStats ? Math.round((logStats.success / logStats.total) * 1000) / 10 : null,
description: metadata?.description || fallbackDescription,
tags: metadata ? Array.from(metadata.tags).sort((a, b) => a.localeCompare(b)) : [],
supportedEndpointTypes: metadata ? Array.from(metadata.supportedEndpointTypes).sort((a, b) => a.localeCompare(b)) : [],
pricingSources: metadata?.pricingSources || [],
accounts,
};
});
models.sort((a, b) => b.accountCount - a.accountCount);
writeModelsMarketplaceCache(includePricing, models);
return {
models,
meta: {
refreshRequested,
refreshQueued,
refreshReused,
refreshRunning: !!runningRefreshTask,
refreshJobId,
includePricing,
},
};
});
app.get('/api/models/token-candidates', async () => {
const rows = db.select().from(schema.tokenModelAvailability)
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const result: Record<string, Array<{
accountId: number;
tokenId: number;
tokenName: string;
isDefault: boolean;
username: string | null;
siteId: number;
siteName: string;
}>> = {};
for (const row of rows) {
const modelName = row.token_model_availability.modelName;
if (!result[modelName]) result[modelName] = [];
if (result[modelName].some((item) => item.tokenId === row.account_tokens.id)) continue;
result[modelName].push({
accountId: row.accounts.id,
tokenId: row.account_tokens.id,
tokenName: row.account_tokens.name,
isDefault: !!row.account_tokens.isDefault,
username: row.accounts.username,
siteId: row.sites.id,
siteName: row.sites.name,
});
}
return { models: result };
});
// Refresh models for one account and rebuild routes.
app.post<{ Params: { accountId: string } }>('/api/models/check/:accountId', async (request) => {
const accountId = Number.parseInt(request.params.accountId, 10);
if (Number.isNaN(accountId)) {
return { success: false, error: 'Invalid account id' };
}
const refresh = await refreshModelsForAccount(accountId);
const rebuild = rebuildTokenRoutesFromAvailability();
return { success: true, refresh, rebuild };
});
// Site distribution – per-site aggregate data
app.get('/api/stats/site-distribution', async () => {
const accounts = db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.sites.status, 'active'))
.all();
const proxyLogs = db.select().from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.sites.status, 'active'))
.all();
// Build spend per site from proxy logs
const spendBySiteId: Record<number, number> = {};
for (const row of proxyLogs) {
const siteId = row.sites?.id;
if (siteId == null) continue;
const log = row.proxy_logs;
const platform = row.sites?.platform || 'new-api';
const explicitCost = typeof log.estimatedCost === 'number' ? log.estimatedCost : 0;
const cost = explicitCost > 0 ? explicitCost : fallbackTokenCost(log.totalTokens || 0, platform);
spendBySiteId[siteId] = (spendBySiteId[siteId] || 0) + cost;
}
// Aggregate accounts by site
const siteMap: Record<number, {
siteName: string;
platform: string;
totalBalance: number;
accountCount: number;
}> = {};
for (const row of accounts) {
const site = row.sites;
const acct = row.accounts;
if (!siteMap[site.id]) {
siteMap[site.id] = { siteName: site.name, platform: site.platform, totalBalance: 0, accountCount: 0 };
}
siteMap[site.id].totalBalance += acct.balance || 0;
siteMap[site.id].accountCount++;
}
const distribution = Object.entries(siteMap).map(([id, info]) => ({
siteId: Number(id),
siteName: info.siteName,
platform: info.platform,
totalBalance: Math.round(info.totalBalance * 1_000_000) / 1_000_000,
totalSpend: Math.round((spendBySiteId[Number(id)] || 0) * 1_000_000) / 1_000_000,
accountCount: info.accountCount,
}));
return { distribution };
});
// Site trend – daily spend/calls broken down by site
app.get<{ Querystring: { days?: string } }>('/api/stats/site-trend', async (request) => {
const days = Math.max(1, parseInt(request.query.days || '7', 10));
const sinceDate = getLocalRangeStartUtc(days);
const rows = db.select().from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(gte(schema.proxyLogs.createdAt, sinceDate), eq(schema.sites.status, 'active')))
.all();
// Group by date + site name
const dayMap: Record<string, Record<string, { spend: number; calls: number }>> = {};
for (const row of rows) {
const log = row.proxy_logs;
const siteName = row.sites?.name || 'unknown';
const platform = row.sites?.platform || 'new-api';
const date = toLocalDayKeyFromStoredUtc(log.createdAt);
if (!date) continue;
if (!dayMap[date]) dayMap[date] = {};
if (!dayMap[date][siteName]) dayMap[date][siteName] = { spend: 0, calls: 0 };
const explicitCost = typeof log.estimatedCost === 'number' ? log.estimatedCost : 0;
const cost = explicitCost > 0 ? explicitCost : fallbackTokenCost(log.totalTokens || 0, platform);
dayMap[date][siteName].spend += cost;
dayMap[date][siteName].calls++;
}
// Round spend values and sort by date
const trend = Object.entries(dayMap)
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, sites]) => {
const rounded: Record<string, { spend: number; calls: number }> = {};
for (const [name, stats] of Object.entries(sites)) {
rounded[name] = {
spend: Math.round(stats.spend * 1_000_000) / 1_000_000,
calls: stats.calls,
};
}
return { date, sites: rounded };
});
return { trend };
});
// Model stats by site
app.get<{ Querystring: { siteId?: string; days?: string } }>('/api/stats/model-by-site', async (request) => {
const siteId = request.query.siteId ? parseInt(request.query.siteId, 10) : null;
const days = Math.max(1, parseInt(request.query.days || '7', 10));
const sinceDate = getLocalRangeStartUtc(days);
// Get account IDs belonging to the site (if filtered)
let accountIds: Set<number> | null = null;
if (siteId != null && !Number.isNaN(siteId)) {
const siteAccounts = db.select().from(schema.accounts)
.where(eq(schema.accounts.siteId, siteId)).all();
accountIds = new Set(siteAccounts.map((a) => a.id));
}
const rows = db.select().from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(gte(schema.proxyLogs.createdAt, sinceDate), eq(schema.sites.status, 'active')))
.all();
const modelMap: Record<string, { calls: number; spend: number; tokens: number }> = {};
for (const row of rows) {
const log = row.proxy_logs;
// Filter by site if siteId is specified
if (accountIds != null && (log.accountId == null || !accountIds.has(log.accountId))) continue;
const model = log.modelActual || log.modelRequested || 'unknown';
const platform = row.sites?.platform || 'new-api';
if (!modelMap[model]) modelMap[model] = { calls: 0, spend: 0, tokens: 0 };
modelMap[model].calls++;
modelMap[model].tokens += log.totalTokens || 0;
const explicitCost = typeof log.estimatedCost === 'number' ? log.estimatedCost : 0;
const cost = explicitCost > 0 ? explicitCost : fallbackTokenCost(log.totalTokens || 0, platform);
modelMap[model].spend += cost;
}
const models = Object.entries(modelMap)
.map(([model, stats]) => ({
model,
calls: stats.calls,
spend: Math.round(stats.spend * 1_000_000) / 1_000_000,
tokens: stats.tokens,
}))
.sort((a, b) => b.calls - a.calls);
return { models };
});
}
+22
View File
@@ -0,0 +1,22 @@
import { FastifyInstance } from 'fastify';
import { getBackgroundTask, listBackgroundTasks } from '../../services/backgroundTaskService.js';
export async function taskRoutes(app: FastifyInstance) {
app.get<{ Querystring: { limit?: string } }>('/api/tasks', async (request) => {
const limit = Number.parseInt(request.query.limit || '50', 10);
return {
tasks: listBackgroundTasks(limit),
};
});
app.get<{ Params: { id: string } }>('/api/tasks/:id', async (request, reply) => {
const task = getBackgroundTask(request.params.id);
if (!task) {
return reply.code(404).send({ success: false, message: 'task not found' });
}
return {
success: true,
task,
};
});
}
+451
View File
@@ -0,0 +1,451 @@
import { FastifyInstance, FastifyReply } from 'fastify';
import { randomUUID } from 'node:crypto';
import { fetch } from 'undici';
import { config } from '../../config.js';
type TestChatMessage = { role: string; content: string };
type TestTargetFormat = 'openai' | 'claude';
type TestChatRequestBody = {
model?: string;
messages?: TestChatMessage[];
targetFormat?: TestTargetFormat;
stream?: boolean;
temperature?: number;
top_p?: number;
max_tokens?: number;
frequency_penalty?: number;
presence_penalty?: number;
seed?: number;
};
type ValidatedTestChatPayload = {
model: string;
messages: TestChatMessage[];
targetFormat: TestTargetFormat;
stream?: boolean;
temperature?: number;
top_p?: number;
max_tokens?: number;
frequency_penalty?: number;
presence_penalty?: number;
seed?: number;
};
type TestChatJobStatus = 'pending' | 'succeeded' | 'failed' | 'cancelled';
type TestChatJob = {
id: string;
status: TestChatJobStatus;
payload: ValidatedTestChatPayload;
result?: unknown;
error?: unknown;
controller?: AbortController | null;
createdAt: number;
updatedAt: number;
expiresAt: number;
};
const JOB_TTL_MS = 10 * 60 * 1000;
const JOB_CLEANUP_INTERVAL_MS = 60 * 1000;
const jobs = new Map<string, TestChatJob>();
class UpstreamProxyError extends Error {
statusCode: number;
responsePayload: unknown;
constructor(statusCode: number, responsePayload: unknown) {
super(`Upstream request failed with status ${statusCode}`);
this.name = 'UpstreamProxyError';
this.statusCode = statusCode;
this.responsePayload = responsePayload;
}
}
const normalizeErrorPayload = (text: string): unknown => {
try {
return JSON.parse(text);
} catch {
return { error: { message: text, type: 'upstream_error' } };
}
};
const validatePayload = (
body: TestChatRequestBody,
reply: FastifyReply,
): ValidatedTestChatPayload | null => {
if (!body.model || body.model.trim().length === 0) {
reply.code(400).send({ error: 'model is required' });
return null;
}
if (!Array.isArray(body.messages) || body.messages.length === 0) {
reply.code(400).send({ error: 'messages is required' });
return null;
}
const targetFormat: TestTargetFormat = body.targetFormat === 'claude' ? 'claude' : 'openai';
return {
model: body.model,
messages: body.messages,
targetFormat,
stream: body.stream,
temperature: body.temperature,
top_p: body.top_p,
max_tokens: body.max_tokens,
frequency_penalty: body.frequency_penalty,
presence_penalty: body.presence_penalty,
seed: body.seed,
};
};
const convertOpenAiPayloadToClaudeBody = (
payload: ValidatedTestChatPayload,
forceStream: boolean,
): Record<string, unknown> => {
const systemContents: string[] = [];
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
for (const message of payload.messages) {
const role = typeof message.role === 'string' ? message.role : 'user';
const content = typeof message.content === 'string' ? message.content : '';
if (!content.trim()) continue;
if (role === 'system') {
systemContents.push(content);
continue;
}
messages.push({
role: role === 'assistant' ? 'assistant' : 'user',
content,
});
}
const body: Record<string, unknown> = {
model: payload.model,
stream: forceStream,
max_tokens: typeof payload.max_tokens === 'number' && Number.isFinite(payload.max_tokens)
? payload.max_tokens
: 4096,
messages,
};
if (systemContents.length > 0) {
body.system = systemContents.join('\n\n');
}
if (typeof payload.temperature === 'number' && Number.isFinite(payload.temperature)) {
body.temperature = payload.temperature;
}
if (typeof payload.top_p === 'number' && Number.isFinite(payload.top_p)) {
body.top_p = payload.top_p;
}
return body;
};
const buildUpstreamRequest = (
payload: ValidatedTestChatPayload,
forceStream: boolean,
): { url: string; headers: Record<string, string>; body: Record<string, unknown> } => {
if (payload.targetFormat === 'claude') {
return {
url: `http://127.0.0.1:${config.port}/v1/messages`,
headers: {
'Content-Type': 'application/json',
'x-api-key': config.proxyToken,
'anthropic-version': '2023-06-01',
},
body: convertOpenAiPayloadToClaudeBody(payload, forceStream),
};
}
const { targetFormat: _targetFormat, ...openAiPayload } = payload;
return {
url: `http://127.0.0.1:${config.port}/v1/chat/completions`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.proxyToken}`,
},
body: { ...openAiPayload, stream: forceStream },
};
};
const requestUpstreamChat = async (
payload: ValidatedTestChatPayload,
signal?: AbortSignal,
forceStream = false,
): Promise<unknown> => {
const upstreamRequest = buildUpstreamRequest(payload, forceStream);
const upstream = await fetch(upstreamRequest.url, {
method: 'POST',
headers: upstreamRequest.headers,
body: JSON.stringify(upstreamRequest.body),
signal,
});
const text = await upstream.text();
if (!upstream.ok) {
throw new UpstreamProxyError(upstream.status, normalizeErrorPayload(text));
}
try {
return JSON.parse(text);
} catch {
return { raw: text };
}
};
const cleanupExpiredJobs = () => {
const now = Date.now();
for (const [jobId, job] of jobs.entries()) {
if (job.expiresAt <= now) {
jobs.delete(jobId);
}
}
};
const runJob = async (jobId: string) => {
const job = jobs.get(jobId);
if (!job || job.status !== 'pending') return;
const controller = new AbortController();
job.controller = controller;
try {
const result = await requestUpstreamChat(job.payload, controller.signal);
const current = jobs.get(jobId);
if (!current) return;
current.controller = null;
current.status = 'succeeded';
current.result = result;
current.updatedAt = Date.now();
current.expiresAt = current.updatedAt + JOB_TTL_MS;
} catch (error) {
const current = jobs.get(jobId);
if (!current) return;
current.controller = null;
if ((error as any)?.name === 'AbortError') {
current.status = 'cancelled';
current.error = { error: { message: 'job cancelled', type: 'cancelled' } };
current.updatedAt = Date.now();
current.expiresAt = current.updatedAt + 30_000;
return;
}
current.status = 'failed';
current.error = error instanceof UpstreamProxyError
? error.responsePayload
: { error: { message: (error as any)?.message || 'proxy request failed', type: 'server_error' } };
current.updatedAt = Date.now();
current.expiresAt = current.updatedAt + JOB_TTL_MS;
}
};
export async function testRoutes(app: FastifyInstance) {
const cleanupTimer = setInterval(cleanupExpiredJobs, JOB_CLEANUP_INTERVAL_MS);
cleanupTimer.unref?.();
app.addHook('onClose', async () => {
clearInterval(cleanupTimer);
});
app.post<{ Body: TestChatRequestBody }>(
'/api/test/chat',
async (request, reply) => {
const body = request.body || {};
const payload = validatePayload(body, reply);
if (!payload) return;
try {
const data = await requestUpstreamChat(payload, undefined, false);
return reply.send(data);
} catch (error) {
if (error instanceof UpstreamProxyError) {
return reply.code(error.statusCode).send(error.responsePayload);
}
return reply.code(502).send({
error: {
message: (error as any)?.message || 'proxy request failed',
type: 'server_error',
},
});
}
},
);
app.post<{ Body: TestChatRequestBody }>(
'/api/test/chat/stream',
async (request, reply) => {
const body = request.body || {};
const payload = validatePayload(body, reply);
if (!payload) return;
const controller = new AbortController();
const abortUpstream = () => {
try {
if (!controller.signal.aborted) {
controller.abort();
}
} catch {
// no-op
}
};
const onClientAborted = () => {
abortUpstream();
};
const onClientClosed = () => {
if (!reply.raw.writableEnded) {
abortUpstream();
}
};
const cleanupClientListeners = () => {
request.raw.off?.('aborted', onClientAborted);
reply.raw.off?.('close', onClientClosed);
};
request.raw.on('aborted', onClientAborted);
reply.raw.on('close', onClientClosed);
let upstream;
try {
const upstreamRequest = buildUpstreamRequest(payload, true);
upstream = await fetch(upstreamRequest.url, {
method: 'POST',
headers: upstreamRequest.headers,
body: JSON.stringify(upstreamRequest.body),
signal: controller.signal,
});
} catch (error) {
cleanupClientListeners();
return reply.code(502).send({
error: {
message: (error as any)?.message || 'proxy request failed',
type: 'server_error',
},
});
}
if (!upstream.ok) {
const text = await upstream.text();
cleanupClientListeners();
return reply.code(upstream.status).send(normalizeErrorPayload(text));
}
reply.hijack();
reply.raw.statusCode = 200;
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no');
const reader = upstream.body?.getReader();
if (!reader) {
cleanupClientListeners();
reply.raw.end();
return;
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
reply.raw.write(Buffer.from(value));
}
}
} catch (error) {
if (!reply.raw.writableEnded) {
const message = JSON.stringify({
error: { message: (error as any)?.message || 'stream interrupted', type: 'stream_error' },
});
reply.raw.write(`event: error\ndata: ${message}\n\n`);
}
} finally {
try {
await reader.cancel();
} catch {
// no-op
}
cleanupClientListeners();
if (!reply.raw.writableEnded) {
reply.raw.end();
}
}
},
);
app.post<{ Body: TestChatRequestBody }>(
'/api/test/chat/jobs',
async (request, reply) => {
const body = request.body || {};
const payload = validatePayload(body, reply);
if (!payload) return;
const now = Date.now();
const jobId = randomUUID();
const job: TestChatJob = {
id: jobId,
status: 'pending',
payload,
controller: null,
createdAt: now,
updatedAt: now,
expiresAt: now + JOB_TTL_MS,
};
jobs.set(jobId, job);
void runJob(jobId);
return reply.code(202).send({
jobId,
status: job.status,
createdAt: new Date(job.createdAt).toISOString(),
expiresAt: new Date(job.expiresAt).toISOString(),
});
},
);
app.get<{ Params: { jobId: string } }>(
'/api/test/chat/jobs/:jobId',
async (request, reply) => {
const job = jobs.get(request.params.jobId);
if (!job) {
return reply.code(404).send({ error: { message: 'job not found', type: 'not_found' } });
}
return reply.send({
jobId: job.id,
status: job.status,
result: job.result,
error: job.error,
createdAt: new Date(job.createdAt).toISOString(),
updatedAt: new Date(job.updatedAt).toISOString(),
expiresAt: new Date(job.expiresAt).toISOString(),
});
},
);
app.delete<{ Params: { jobId: string } }>(
'/api/test/chat/jobs/:jobId',
async (request, reply) => {
const job = jobs.get(request.params.jobId);
if (!job) {
return reply.code(404).send({ error: { message: 'job not found', type: 'not_found' } });
}
if (job.status === 'pending' && job.controller) {
try {
job.controller.abort();
} catch {
// no-op
}
}
jobs.delete(request.params.jobId);
return reply.send({ success: true });
},
);
}
+157
View File
@@ -0,0 +1,157 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../../db/index.js');
describe('PUT /api/channels/batch', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
let seedId = 0;
const nextId = () => {
seedId += 1;
return seedId;
};
const seedChannel = (options: { priority: number; weight: number; manualOverride?: boolean }) => {
const id = nextId();
const site = db.insert(schema.sites).values({
name: `site-${id}`,
url: `https://example.com/${id}`,
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
accessToken: `access-token-${id}`,
apiToken: `api-token-${id}`,
}).returning().get();
const route = db.insert(schema.tokenRoutes).values({
modelPattern: `gpt-4o-${id}`,
enabled: true,
}).returning().get();
return db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
priority: options.priority,
weight: options.weight,
manualOverride: options.manualOverride ?? false,
}).returning().get();
};
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-tokens-batch-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./tokens.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.tokensRoutes);
});
beforeEach(() => {
db.delete(schema.routeChannels).run();
db.delete(schema.accountTokens).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
});
it('returns 400 when updates is missing or empty', async () => {
const missingRes = await app.inject({
method: 'PUT',
url: '/api/channels/batch',
payload: {},
});
expect(missingRes.statusCode).toBe(400);
expect(missingRes.json()).toMatchObject({ success: false });
const emptyRes = await app.inject({
method: 'PUT',
url: '/api/channels/batch',
payload: { updates: [] },
});
expect(emptyRes.statusCode).toBe(400);
expect(emptyRes.json()).toMatchObject({ success: false });
});
it('returns 400 when an update item is invalid', async () => {
const invalidIdRes = await app.inject({
method: 'PUT',
url: '/api/channels/batch',
payload: {
updates: [{ id: '1', priority: 1 }],
},
});
expect(invalidIdRes.statusCode).toBe(400);
expect(invalidIdRes.json()).toMatchObject({ success: false });
const invalidPriorityRes = await app.inject({
method: 'PUT',
url: '/api/channels/batch',
payload: {
updates: [{ id: 1, priority: null }],
},
});
expect(invalidPriorityRes.statusCode).toBe(400);
expect(invalidPriorityRes.json()).toMatchObject({ success: false });
});
it('updates priorities in batch, sets manualOverride, and keeps weight unchanged', async () => {
const channelA = seedChannel({ priority: 9, weight: 17, manualOverride: false });
const channelB = seedChannel({ priority: 8, weight: 23, manualOverride: false });
const res = await app.inject({
method: 'PUT',
url: '/api/channels/batch',
payload: {
updates: [
{ id: channelA.id, priority: 3.8 },
{ id: channelB.id, priority: -7.2 },
],
},
});
expect(res.statusCode).toBe(200);
const body = res.json() as {
success: boolean;
channels: Array<{ id: number; priority: number; weight: number; manualOverride: boolean }>;
};
expect(body.success).toBe(true);
expect(body.channels).toHaveLength(2);
const returnedA = body.channels.find((channel) => channel.id === channelA.id);
const returnedB = body.channels.find((channel) => channel.id === channelB.id);
expect(returnedA).toBeDefined();
expect(returnedB).toBeDefined();
expect(returnedA?.priority).toBe(3);
expect(returnedB?.priority).toBe(0);
expect(returnedA?.weight).toBe(17);
expect(returnedB?.weight).toBe(23);
expect(returnedA?.manualOverride).toBe(true);
expect(returnedB?.manualOverride).toBe(true);
const dbA = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelA.id)).get();
const dbB = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelB.id)).get();
expect(dbA?.priority).toBe(3);
expect(dbB?.priority).toBe(0);
expect(dbA?.weight).toBe(17);
expect(dbB?.weight).toBe(23);
expect(dbA?.manualOverride).toBe(true);
expect(dbB?.manualOverride).toBe(true);
});
});
@@ -0,0 +1,102 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
type DbModule = typeof import('../../db/index.js');
describe('POST /api/routes/decision/batch', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
let seedId = 0;
const nextId = () => {
seedId += 1;
return seedId;
};
const seedRoutableChannel = () => {
const id = nextId();
const site = db.insert(schema.sites).values({
name: `site-${id}`,
url: `https://site-${id}.example.com`,
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: `user-${id}`,
accessToken: `access-token-${id}`,
apiToken: `sk-api-token-${id}`,
status: 'active',
}).returning().get();
const route = db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
enabled: true,
}).returning().get();
db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: null,
priority: 0,
weight: 10,
enabled: true,
}).run();
};
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-route-decision-batch-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./tokens.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.tokensRoutes);
});
beforeEach(() => {
seedId = 0;
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('returns decisions for multiple requested models in one call', async () => {
seedRoutableChannel();
const response = await app.inject({
method: 'POST',
url: '/api/routes/decision/batch',
payload: {
models: ['gpt-4o-mini', 'gpt-4o-mini', 'unknown-model', ''],
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
success: boolean;
decisions: Record<string, { matched: boolean; candidates: Array<unknown> }>;
};
expect(body.success).toBe(true);
expect(Object.keys(body.decisions).sort()).toEqual(['gpt-4o-mini', 'unknown-model']);
expect(body.decisions['gpt-4o-mini']?.matched).toBe(true);
expect(Array.isArray(body.decisions['gpt-4o-mini']?.candidates)).toBe(true);
expect(body.decisions['gpt-4o-mini']?.candidates.length).toBeGreaterThan(0);
expect(body.decisions['unknown-model']?.matched).toBe(false);
});
});
+358
View File
@@ -0,0 +1,358 @@
import { FastifyInstance } from 'fastify';
import { and, eq, inArray } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
function isExactModelPattern(modelPattern: string): boolean {
return !/[\*\?\[]/.test(modelPattern);
}
function getDefaultTokenId(accountId: number): number | null {
const token = db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true), eq(schema.accountTokens.isDefault, true)))
.get();
return token?.id ?? null;
}
function tokenSupportsModel(tokenId: number, modelName: string): boolean {
const row = db.select().from(schema.tokenModelAvailability)
.where(
and(
eq(schema.tokenModelAvailability.tokenId, tokenId),
eq(schema.tokenModelAvailability.modelName, modelName),
eq(schema.tokenModelAvailability.available, true),
),
)
.get();
return !!row;
}
function checkTokenBelongsToAccount(tokenId: number, accountId: number): boolean {
const row = db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.id, tokenId), eq(schema.accountTokens.accountId, accountId)))
.get();
return !!row;
}
type BatchChannelPriorityUpdate = {
id: number;
priority: number;
};
type BatchRouteDecisionModels = {
models: string[];
};
function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } {
if (!input || typeof input !== 'object') {
return { ok: false, message: '请求体必须是对象' };
}
const updates = (input as { updates?: unknown }).updates;
if (!Array.isArray(updates) || updates.length === 0) {
return { ok: false, message: 'updates 必须是非空数组' };
}
const normalized: BatchChannelPriorityUpdate[] = [];
for (let index = 0; index < updates.length; index += 1) {
const item = updates[index];
if (!item || typeof item !== 'object') {
return { ok: false, message: `updates[${index}] 必须是对象` };
}
const { id, priority } = item as { id?: unknown; priority?: unknown };
if (typeof id !== 'number' || !Number.isFinite(id)) {
return { ok: false, message: `updates[${index}].id 必须是有限数字` };
}
if (typeof priority !== 'number' || !Number.isFinite(priority)) {
return { ok: false, message: `updates[${index}].priority 必须是有限数字` };
}
const normalizedId = Math.trunc(id);
if (normalizedId <= 0) {
return { ok: false, message: `updates[${index}].id 必须大于 0` };
}
normalized.push({
id: normalizedId,
priority: Math.max(0, Math.trunc(priority)),
});
}
return { ok: true, updates: normalized };
}
function parseBatchRouteDecisionModels(input: unknown): { ok: true; models: string[] } | { ok: false; message: string } {
if (!input || typeof input !== 'object') {
return { ok: false, message: '请求体必须是对象' };
}
const models = (input as BatchRouteDecisionModels).models;
if (!Array.isArray(models) || models.length === 0) {
return { ok: false, message: 'models 必须是非空数组' };
}
const dedupe = new Set<string>();
const normalized: string[] = [];
for (const raw of models) {
if (typeof raw !== 'string') continue;
const trimmed = raw.trim();
if (!trimmed || dedupe.has(trimmed)) continue;
dedupe.add(trimmed);
normalized.push(trimmed);
if (normalized.length >= 500) break;
}
if (normalized.length === 0) {
return { ok: false, message: 'models 中没有有效模型名称' };
}
return { ok: true, models: normalized };
}
export async function tokensRoutes(app: FastifyInstance) {
// List all routes
app.get('/api/routes', async () => {
const routes = db.select().from(schema.tokenRoutes).all();
if (routes.length === 0) return [];
const routeIds = routes.map((route) => route.id);
const channelRows = db.select().from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
.where(inArray(schema.routeChannels.routeId, routeIds))
.all();
const channelsByRoute = new Map<number, any[]>();
for (const row of channelRows) {
const routeId = row.route_channels.routeId;
if (!channelsByRoute.has(routeId)) channelsByRoute.set(routeId, []);
channelsByRoute.get(routeId)!.push({
...row.route_channels,
account: row.accounts,
site: row.sites,
token: row.account_tokens
? {
id: row.account_tokens.id,
name: row.account_tokens.name,
accountId: row.account_tokens.accountId,
enabled: row.account_tokens.enabled,
isDefault: row.account_tokens.isDefault,
}
: null,
});
}
return routes.map((route) => ({
...route,
channels: channelsByRoute.get(route.id) || [],
}));
});
app.get<{ Querystring: { model?: string } }>('/api/routes/decision', async (request, reply) => {
const model = (request.query.model || '').trim();
if (!model) {
return reply.code(400).send({ success: false, message: 'model 不能为空' });
}
const decision = tokenRouter.explainSelection(model);
return { success: true, decision };
});
app.post<{ Body: BatchRouteDecisionModels }>('/api/routes/decision/batch', async (request, reply) => {
const parsed = parseBatchRouteDecisionModels(request.body);
if (!parsed.ok) {
return reply.code(400).send({ success: false, message: parsed.message });
}
const decisions: Record<string, ReturnType<typeof tokenRouter.explainSelection>> = {};
for (const model of parsed.models) {
decisions[model] = tokenRouter.explainSelection(model);
}
return { success: true, decisions };
});
// Create a route
app.post<{ Body: { modelPattern: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => {
const body = request.body;
return db.insert(schema.tokenRoutes).values({
modelPattern: body.modelPattern,
modelMapping: body.modelMapping,
enabled: body.enabled ?? true,
}).returning().get();
});
// Update a route
app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request) => {
const id = parseInt(request.params.id, 10);
const body = request.body as Record<string, unknown>;
const updates: Record<string, unknown> = {};
if (body.modelPattern !== undefined) updates.modelPattern = body.modelPattern;
if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping;
if (body.enabled !== undefined) updates.enabled = body.enabled;
updates.updatedAt = new Date().toISOString();
db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run();
return db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
});
// Delete a route
app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => {
const id = parseInt(request.params.id, 10);
db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run();
return { success: true };
});
// Add a channel to a route
app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const body = request.body;
const route = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
const effectiveTokenId = body.tokenId ?? getDefaultTokenId(body.accountId);
if (body.tokenId && !checkTokenBelongsToAccount(body.tokenId, body.accountId)) {
return reply.code(400).send({ success: false, message: '令牌不存在或不属于当前账号' });
}
if (isExactModelPattern(route.modelPattern) && effectiveTokenId && !tokenSupportsModel(effectiveTokenId, route.modelPattern)) {
return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' });
}
return db.insert(schema.routeChannels).values({
routeId,
accountId: body.accountId,
tokenId: body.tokenId,
priority: body.priority ?? 0,
weight: body.weight ?? 10,
}).returning().get();
});
// Batch update channel priorities
app.put<{ Body: { updates: Array<{ id: number; priority: number }> } }>('/api/channels/batch', async (request, reply) => {
const parsed = parseBatchChannelUpdates(request.body);
if (!parsed.ok) {
return reply.code(400).send({ success: false, message: parsed.message });
}
const channelIds = Array.from(new Set(parsed.updates.map((update) => update.id)));
const existingChannels = db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.id, channelIds))
.all();
if (existingChannels.length !== channelIds.length) {
const existingIds = new Set(existingChannels.map((channel) => channel.id));
const missingId = channelIds.find((id) => !existingIds.has(id));
return reply.code(404).send({ success: false, message: `通道不存在: ${missingId}` });
}
for (const update of parsed.updates) {
db.update(schema.routeChannels).set({
priority: update.priority,
manualOverride: true,
}).where(eq(schema.routeChannels.id, update.id)).run();
}
const updatedChannels = db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.id, channelIds))
.all();
return { success: true, channels: updatedChannels };
});
// Update a channel
app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => {
const channelId = parseInt(request.params.channelId, 10);
const body = request.body as Record<string, unknown>;
const channel = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!channel) {
return reply.code(404).send({ success: false, message: '通道不存在' });
}
const route = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, channel.routeId)).get();
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
if (body.tokenId !== undefined && body.tokenId !== null) {
const tokenId = Number(body.tokenId);
if (!Number.isFinite(tokenId) || !checkTokenBelongsToAccount(tokenId, channel.accountId)) {
return reply.code(400).send({ success: false, message: '令牌不存在或不属于通道账号' });
}
}
const nextTokenId = body.tokenId === undefined
? (channel.tokenId ?? getDefaultTokenId(channel.accountId))
: (body.tokenId === null ? getDefaultTokenId(channel.accountId) : Number(body.tokenId));
if (isExactModelPattern(route.modelPattern) && nextTokenId && !tokenSupportsModel(nextTokenId, route.modelPattern)) {
return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' });
}
const updates: Record<string, unknown> = { manualOverride: true };
for (const key of ['priority', 'weight', 'enabled', 'tokenId']) {
if (body[key] !== undefined) updates[key] = body[key];
}
db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
return db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
});
// Delete a channel
app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => {
const channelId = parseInt(request.params.channelId, 10);
db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
return { success: true };
});
// Rebuild routes/channels from model availability.
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
const body = (request.body || {}) as { refreshModels?: boolean };
if (body.refreshModels === false) {
const rebuild = rebuildTokenRoutesFromAvailability();
return { success: true, rebuild };
}
if ((request.body as { wait?: boolean } | undefined)?.wait) {
const result = await refreshModelsAndRebuildRoutes();
return { success: true, ...result };
}
const { task, reused } = startBackgroundTask(
{
type: 'route',
title: '刷新模型并重建路由',
dedupeKey: 'refresh-models-and-rebuild-routes',
notifyOnFailure: true,
successMessage: (currentTask) => {
const rebuild = (currentTask.result as any)?.rebuild;
if (!rebuild) return '刷新模型并重建路由已完成';
return `刷新模型并重建路由完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}`;
},
failureMessage: (currentTask) => `刷新模型并重建路由失败:${currentTask.error || 'unknown error'}`,
},
async () => refreshModelsAndRebuildRoutes(),
);
return reply.code(202).send({
success: true,
queued: true,
reused,
jobId: task.id,
status: task.status,
message: reused
? '路由重建任务执行中,请稍后查看程序日志'
: '已开始路由重建,请稍后查看程序日志',
});
});
}
+317
View File
@@ -0,0 +1,317 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
const selectNextChannelMock = vi.fn();
const recordSuccessMock = vi.fn();
const recordFailureMock = vi.fn();
const refreshModelsAndRebuildRoutesMock = vi.fn();
const reportProxyAllFailedMock = vi.fn();
const reportTokenExpiredMock = vi.fn();
const estimateProxyCostMock = vi.fn(async (_arg?: any) => 0);
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({
...usage,
estimatedCostFromQuota: 0,
recoveredFromSelfLog: false,
}));
const dbInsertMock = vi.fn((_arg?: any) => ({
values: () => ({
run: () => undefined,
}),
}));
vi.mock('undici', () => ({
fetch: (...args: unknown[]) => fetchMock(...args),
}));
vi.mock('../../services/tokenRouter.js', () => ({
tokenRouter: {
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
},
}));
vi.mock('../../services/modelService.js', () => ({
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
}));
vi.mock('../../services/alertService.js', () => ({
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
}));
vi.mock('../../services/alertRules.js', () => ({
isTokenExpiredError: () => false,
}));
vi.mock('../../services/modelPricingService.js', () => ({
estimateProxyCost: (arg: any) => estimateProxyCostMock(arg),
}));
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
}));
vi.mock('../../services/proxyUsageFallbackService.js', () => ({
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
}));
vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
schema: {
proxyLogs: {},
},
}));
describe('chat proxy stream behavior', () => {
let app: FastifyInstance;
beforeAll(async () => {
const { chatProxyRoute, claudeMessagesProxyRoute } = await import('./chat.js');
app = Fastify();
await app.register(chatProxyRoute);
await app.register(claudeMessagesProxyRoute);
});
beforeEach(() => {
fetchMock.mockReset();
selectChannelMock.mockReset();
selectNextChannelMock.mockReset();
recordSuccessMock.mockReset();
recordFailureMock.mockReset();
refreshModelsAndRebuildRoutesMock.mockReset();
reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset();
estimateProxyCostMock.mockClear();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
dbInsertMock.mockClear();
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'demo-site', url: 'https://upstream.example.com' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'upstream-gpt',
});
selectNextChannelMock.mockReturnValue(null);
});
afterAll(async () => {
await app.close();
});
it('converts non-SSE upstream streaming responses into SSE events', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-demo',
object: 'chat.completion',
created: 1_706_000_000,
model: 'upstream-gpt',
choices: [{
index: 0,
message: { role: 'assistant', content: 'hello from upstream' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-4o-mini',
stream: true,
messages: [{ role: 'user', content: 'hi' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/event-stream');
expect(response.body).toContain('data: ');
expect(response.body).toContain('"chat.completion.chunk"');
expect(response.body).toContain('hello from upstream');
expect(response.body).toContain('data: [DONE]');
});
it('sets anti-buffering SSE headers for streamed chat responses', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'));
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
},
});
fetchMock.mockResolvedValue(new Response(upstreamBody, {
status: 200,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-4o-mini',
stream: true,
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/event-stream');
expect(response.headers['cache-control']).toContain('no-transform');
expect(response.headers['x-accel-buffering']).toBe('no');
expect(response.body).toContain('"chat.completion.chunk"');
expect(response.body).toContain('"delta":{"role":"assistant","content":"hello"}');
expect(response.body).toContain('data: [DONE]');
});
it('normalizes anthropic-style SSE events into OpenAI chunks for clients like OpenWebUI', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('event: message_start\ndata: {"type":"message_start","message":{"id":"msg_123","model":"claude-opus-4-6"}}\n\n'));
controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hello"}}\n\n'));
controller.enqueue(encoder.encode('event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n'));
controller.enqueue(encoder.encode('event: message_stop\ndata: {"type":"message_stop"}\n\n'));
controller.close();
},
});
fetchMock.mockResolvedValue(new Response(upstreamBody, {
status: 200,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'claude-opus-4-6',
stream: true,
messages: [{ role: 'user', content: 'who are you' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/event-stream');
expect(response.body).toContain('"chat.completion.chunk"');
expect(response.body).toContain('"delta":{"content":"hello"}');
expect(response.body).toContain('"finish_reason":"stop"');
expect(response.body).toContain('data: [DONE]');
});
it('emits OpenAI-compatible assistant starter chunk for anthropic message_start events', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('event: message_start\ndata: {"type":"message_start","message":{"id":"msg_compat","model":"claude-opus-4-6"}}\n\n'));
controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"compat"}}\n\n'));
controller.enqueue(encoder.encode('event: message_stop\ndata: {"type":"message_stop"}\n\n'));
controller.close();
},
});
fetchMock.mockResolvedValue(new Response(upstreamBody, {
status: 200,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'claude-opus-4-6',
stream: true,
messages: [{ role: 'user', content: 'compat test' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain('"delta":{"role":"assistant","content":""}');
expect(response.body).toContain('"delta":{"content":"compat"}');
expect(response.body).toContain('data: [DONE]');
});
it('converts OpenAI non-stream responses into Claude message format on /v1/messages', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-upstream',
object: 'chat.completion',
created: 1_706_000_001,
model: 'claude-opus-4-6',
choices: [{
index: 0,
message: { role: 'assistant', content: 'hello from claude format' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 120, completion_tokens: 16, total_tokens: 136 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'claude-opus-4-6',
max_tokens: 256,
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body.type).toBe('message');
expect(body.role).toBe('assistant');
expect(body.model).toBe('claude-opus-4-6');
expect(body.content?.[0]?.type).toBe('text');
expect(body.content?.[0]?.text).toContain('hello from claude format');
expect(body.stop_reason).toBe('end_turn');
});
it('converts OpenAI SSE chunks into Claude stream events on /v1/messages', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-1","model":"claude-opus-4-6","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-1","model":"claude-opus-4-6","choices":[{"delta":{"content":"hello"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-1","model":"claude-opus-4-6","choices":[{"delta":{},"finish_reason":"stop"}]}\n\n'));
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
},
});
fetchMock.mockResolvedValue(new Response(upstreamBody, {
status: 200,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'claude-opus-4-6',
stream: true,
max_tokens: 256,
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/event-stream');
expect(response.body).toContain('event: message_start');
expect(response.body).toContain('event: content_block_delta');
expect(response.body).toContain('\"text\":\"hello\"');
expect(response.body).toContain('event: message_stop');
});
});
+468
View File
@@ -0,0 +1,468 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { tokenRouter } from '../../services/tokenRouter.js';
import { db, schema } from '../../db/index.js';
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 { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
import {
type DownstreamFormat,
createStreamTransformContext,
createClaudeDownstreamContext,
parseDownstreamChatRequest,
pullSseEventsWithDone,
normalizeUpstreamStreamEvent,
serializeNormalizedStreamEvent,
serializeStreamDone,
normalizeUpstreamFinalResponse,
serializeFinalResponse,
buildSyntheticOpenAiChunks,
} from './chatFormats.js';
const MAX_RETRIES = 2;
export async function chatProxyRoute(app: FastifyInstance) {
app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) =>
handleChatProxyRequest(request, reply, 'openai'));
}
export async function claudeMessagesProxyRoute(app: FastifyInstance) {
app.post('/v1/messages', async (request: FastifyRequest, reply: FastifyReply) =>
handleChatProxyRequest(request, reply, 'claude'));
}
async function handleChatProxyRequest(
request: FastifyRequest,
reply: FastifyReply,
downstreamFormat: DownstreamFormat,
) {
const parsedRequest = parseDownstreamChatRequest(request.body, downstreamFormat);
if (parsedRequest.error) {
return reply.code(parsedRequest.error.statusCode).send(parsedRequest.error.payload);
}
const { requestedModel, isStream, upstreamBody } = parsedRequest.value!;
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel);
}
if (!selected) {
await reportProxyAllFailed({
model: requestedModel,
reason: 'No available channels after retries',
});
return reply.code(503).send({
error: { message: 'No available channels for this model', type: 'server_error' },
});
}
excludeChannelIds.push(selected.channel.id);
const targetUrl = `${selected.site.url}/v1/chat/completions`;
const forwardBody = {
...upstreamBody,
model: selected.actualModel,
stream: isStream,
};
const startTime = Date.now();
try {
const upstream = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
});
if (!upstream.ok) {
const errText = await upstream.text().catch(() => 'unknown error');
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount);
if (isTokenExpiredError({ status: upstream.status, message: errText })) {
await reportTokenExpired({
accountId: selected.account.id,
username: selected.account.username,
siteName: selected.site.name,
detail: `HTTP ${upstream.status}`,
});
}
if (shouldRetryProxyRequest(upstream.status, errText) && retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: `upstream returned HTTP ${upstream.status}`,
});
return reply.code(upstream.status).send({
error: { message: errText, type: 'upstream_error' },
});
}
const modelName = selected.actualModel || requestedModel;
if (isStream) {
reply.hijack();
reply.raw.statusCode = 200;
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no');
const streamContext = createStreamTransformContext(modelName);
const claudeContext = createClaudeDownstreamContext();
let parsedUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
const writeLines = (lines: string[]) => {
for (const line of lines) {
reply.raw.write(line);
}
};
const writeDone = () => {
writeLines(serializeStreamDone(downstreamFormat, streamContext, claudeContext));
};
const emitNormalizedFinalAsStream = (upstreamData: unknown, fallbackText = '') => {
const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, modelName, fallbackText);
streamContext.id = normalizedFinal.id;
streamContext.model = normalizedFinal.model;
streamContext.created = normalizedFinal.created;
if (downstreamFormat === 'openai') {
const syntheticChunks = buildSyntheticOpenAiChunks(normalizedFinal);
for (const chunk of syntheticChunks) {
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`);
}
return;
}
writeLines(serializeNormalizedStreamEvent('claude', { role: 'assistant' }, streamContext, claudeContext));
const combinedText = [normalizedFinal.reasoningContent, normalizedFinal.content]
.filter((item) => typeof item === 'string' && item.trim().length > 0)
.join('\n\n');
if (combinedText) {
writeLines(serializeNormalizedStreamEvent('claude', {
contentDelta: combinedText,
}, streamContext, claudeContext));
}
writeLines(serializeNormalizedStreamEvent('claude', {
finishReason: normalizedFinal.finishReason,
}, streamContext, claudeContext));
};
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
if (!upstreamContentType.includes('text/event-stream')) {
const fallbackText = await upstream.text();
let fallbackData: unknown = null;
try {
fallbackData = JSON.parse(fallbackText);
} catch {
fallbackData = fallbackText;
}
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(fallbackData));
emitNormalizedFinalAsStream(fallbackData, fallbackText);
writeDone();
reply.raw.end();
const latency = Date.now() - startTime;
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
tokenValue: selected.tokenValue,
tokenName: selected.tokenName,
modelName,
requestStartedAtMs: startTime,
requestEndedAtMs: startTime + latency,
localLatencyMs: latency,
usage: {
promptTokens: parsedUsage.promptTokens,
completionTokens: parsedUsage.completionTokens,
totalTokens: parsedUsage.totalTokens,
},
});
let estimatedCost = await estimateProxyCost({
site: selected.site,
account: selected.account,
modelName,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
logProxy(
selected,
requestedModel,
'success',
200,
latency,
null,
retryCount,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
);
return;
}
const reader = upstream.body?.getReader();
if (!reader) {
writeDone();
reply.raw.end();
return;
}
const decoder = new TextDecoder();
let sseBuffer = '';
const consumeSseBuffer = (incoming: string): string => {
const pulled = pullSseEventsWithDone(incoming);
for (const eventBlock of pulled.events) {
if (eventBlock.data === '[DONE]') {
writeDone();
continue;
}
let parsedPayload: unknown = null;
try {
parsedPayload = JSON.parse(eventBlock.data);
} catch {
parsedPayload = null;
}
if (parsedPayload && typeof parsedPayload === 'object') {
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload));
const normalizedEvent = normalizeUpstreamStreamEvent(parsedPayload, streamContext, modelName);
writeLines(serializeNormalizedStreamEvent(
downstreamFormat,
normalizedEvent,
streamContext,
claudeContext,
));
continue;
}
if (downstreamFormat === 'openai') {
reply.raw.write(`data: ${eventBlock.data}\n\n`);
} else {
writeLines(serializeNormalizedStreamEvent('claude', {
contentDelta: eventBlock.data,
}, streamContext, claudeContext));
}
}
return pulled.rest;
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
sseBuffer += decoder.decode(value, { stream: true });
sseBuffer = consumeSseBuffer(sseBuffer);
}
sseBuffer += decoder.decode();
if (sseBuffer.trim().length > 0) {
sseBuffer = consumeSseBuffer(`${sseBuffer}\n\n`);
}
} finally {
reader.releaseLock();
writeDone();
reply.raw.end();
}
const latency = Date.now() - startTime;
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
tokenValue: selected.tokenValue,
tokenName: selected.tokenName,
modelName,
requestStartedAtMs: startTime,
requestEndedAtMs: startTime + latency,
localLatencyMs: latency,
usage: {
promptTokens: parsedUsage.promptTokens,
completionTokens: parsedUsage.completionTokens,
totalTokens: parsedUsage.totalTokens,
},
});
let estimatedCost = await estimateProxyCost({
site: selected.site,
account: selected.account,
modelName,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
logProxy(
selected,
requestedModel,
'success',
200,
latency,
null,
retryCount,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
);
return;
}
const rawText = await upstream.text();
let upstreamData: unknown = rawText;
try {
upstreamData = JSON.parse(rawText);
} catch {
upstreamData = rawText;
}
const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(upstreamData);
const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, modelName, rawText);
const downstreamResponse = serializeFinalResponse(downstreamFormat, normalizedFinal, parsedUsage);
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
tokenValue: selected.tokenValue,
tokenName: selected.tokenName,
modelName,
requestStartedAtMs: startTime,
requestEndedAtMs: startTime + latency,
localLatencyMs: latency,
usage: {
promptTokens: parsedUsage.promptTokens,
completionTokens: parsedUsage.completionTokens,
totalTokens: parsedUsage.totalTokens,
},
});
let estimatedCost = await estimateProxyCost({
site: selected.site,
account: selected.account,
modelName,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
logProxy(
selected,
requestedModel,
'success',
200,
latency,
null,
retryCount,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
);
return reply.send(downstreamResponse);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err?.message || 'network error', retryCount);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: err?.message || 'network failure',
});
return reply.code(502).send({
error: {
message: `Upstream error: ${err?.message || 'network failure'}`,
type: 'upstream_error',
},
});
}
}
}
function logProxy(
selected: any,
modelRequested: string,
status: string,
httpStatus: number,
latencyMs: number,
errorMessage: string | null,
retryCount: number,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
) {
try {
db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
modelRequested,
modelActual: selected.actualModel,
status,
httpStatus,
latencyMs,
promptTokens,
completionTokens,
totalTokens,
estimatedCost,
errorMessage,
retryCount,
}).run();
} catch {}
}
+846
View File
@@ -0,0 +1,846 @@
export type DownstreamFormat = 'openai' | 'claude';
export type ParsedSseEvent = {
event: string;
data: string;
};
export type StreamTransformContext = {
id: string;
model: string;
created: number;
roleSent: boolean;
doneSent: boolean;
};
export type ClaudeDownstreamContext = {
messageStarted: boolean;
contentBlockStarted: boolean;
doneSent: boolean;
};
export type NormalizedStreamEvent = {
role?: 'assistant';
contentDelta?: string;
reasoningDelta?: string;
finishReason?: string | null;
done?: boolean;
};
export type NormalizedFinalResponse = {
id: string;
model: string;
created: number;
content: string;
reasoningContent: string;
finishReason: string;
};
export type ParsedDownstreamChatRequest = {
requestedModel: string;
isStream: boolean;
upstreamBody: Record<string, unknown>;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function pickFiniteNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function ensureIntegerTimestamp(value: unknown, fallback: number): number {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return fallback;
return Math.trunc(n);
}
function joinNonEmpty(parts: string[]): string {
return parts.map((item) => item.trim()).filter((item) => item.length > 0).join('\n\n');
}
function textFromPart(part: unknown): string {
if (typeof part === 'string') return part;
if (!isRecord(part)) return '';
if (typeof part.text === 'string') return part.text;
if (typeof part.content === 'string') return part.content;
if (typeof part.output_text === 'string') return part.output_text;
if (typeof part.completion === 'string') return part.completion;
if (typeof part.partial_json === 'string') return part.partial_json;
if (typeof part.reasoning_content === 'string') return part.reasoning_content;
if (typeof part.reasoning === 'string') return part.reasoning;
if (Array.isArray(part.content)) {
return part.content.map((item) => textFromPart(item)).join('');
}
if (isRecord(part.delta)) {
const fromDelta = textFromPart(part.delta);
if (fromDelta) return fromDelta;
}
return '';
}
function extractTextAndReasoning(value: unknown): { content: string; reasoning: string } {
if (typeof value === 'string') return { content: value, reasoning: '' };
if (Array.isArray(value)) {
const contentParts: string[] = [];
const reasoningParts: string[] = [];
for (const item of value) {
if (typeof item === 'string') {
contentParts.push(item);
continue;
}
if (!isRecord(item)) continue;
const type = typeof item.type === 'string' ? item.type : '';
if (type === 'thinking' && typeof item.thinking === 'string') {
reasoningParts.push(item.thinking);
continue;
}
if (type === 'thinking_delta' && typeof item.text === 'string') {
reasoningParts.push(item.text);
continue;
}
if (typeof item.thought === 'boolean' && item.thought && typeof item.text === 'string') {
reasoningParts.push(item.text);
continue;
}
const text = textFromPart(item);
if (text) contentParts.push(text);
}
return {
content: contentParts.join(''),
reasoning: reasoningParts.join(''),
};
}
if (!isRecord(value)) return { content: '', reasoning: '' };
if (Array.isArray(value.parts)) {
return extractTextAndReasoning(value.parts);
}
return {
content: textFromPart(value),
reasoning: '',
};
}
export function normalizeStopReason(raw: unknown): string | null {
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
if (!value) return null;
if (
value === 'end_turn'
|| value === 'stop'
|| value === 'end'
|| value === 'eos'
|| value === 'finished'
|| value === 'stop_sequence'
) {
return 'stop';
}
if (
value === 'max_tokens'
|| value === 'length'
|| value === 'max_output_tokens'
|| value === 'max_tokens_exceeded'
|| value.includes('max')
) {
return 'length';
}
if (value === 'tool_use' || value === 'tool_calls' || value.includes('tool')) {
return 'tool_calls';
}
return 'stop';
}
export function toClaudeStopReason(finishReason: string | null | undefined): string {
const value = normalizeStopReason(finishReason);
if (value === 'length') return 'max_tokens';
if (value === 'tool_calls') return 'tool_use';
return 'end_turn';
}
export function createStreamTransformContext(modelName: string): StreamTransformContext {
return {
id: `chatcmpl-meta-${Date.now()}`,
model: modelName,
created: Math.floor(Date.now() / 1000),
roleSent: false,
doneSent: false,
};
}
export function createClaudeDownstreamContext(): ClaudeDownstreamContext {
return {
messageStarted: false,
contentBlockStarted: false,
doneSent: false,
};
}
function buildClaudeMessageId(sourceId: string): string {
if (sourceId.startsWith('msg_')) return sourceId;
const sanitized = sourceId.replace(/[^A-Za-z0-9_-]/g, '_');
return `msg_${sanitized || Date.now()}`;
}
function serializeSse(event: string, data: unknown): string {
const payload = typeof data === 'string' ? data : JSON.stringify(data);
if (event) {
return `event: ${event}\ndata: ${payload}\n\n`;
}
return `data: ${payload}\n\n`;
}
function extractAssistantContent(choice: any): string {
const messageContent = choice?.message?.content;
const parsedMessage = extractTextAndReasoning(messageContent).content;
if (parsedMessage) return parsedMessage;
const content = extractTextAndReasoning(choice?.content).content;
if (content) return content;
if (typeof choice?.text === 'string' && choice.text.length > 0) return choice.text;
if (typeof choice?.completion === 'string' && choice.completion.length > 0) return choice.completion;
if (typeof choice?.output_text === 'string' && choice.output_text.length > 0) return choice.output_text;
if (typeof choice?.delta?.content === 'string' && choice.delta.content.length > 0) return choice.delta.content;
return '';
}
function extractAssistantReasoning(choice: any): string {
const message = choice?.message || {};
const direct = [
message.reasoning_content,
message.reasoning,
choice?.reasoning_content,
choice?.reasoning,
].find((item) => typeof item === 'string' && item.length > 0);
if (typeof direct === 'string') return direct;
const parsed = extractTextAndReasoning(message.content);
if (parsed.reasoning) return parsed.reasoning;
const nested = extractTextAndReasoning(choice?.content).reasoning;
if (nested) return nested;
return '';
}
function parseClaudeMessageContent(content: unknown): string {
return extractTextAndReasoning(content).content;
}
function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
model: string;
stream: boolean;
messages: Array<{ role: string; content: string }>;
payload: Record<string, unknown>;
} {
const model = typeof body.model === 'string' ? body.model.trim() : '';
const stream = body.stream === true;
const messages: Array<{ role: string; content: string }> = [];
const appendMessage = (role: string, content: unknown) => {
const text = parseClaudeMessageContent(content);
if (!text) return;
messages.push({ role, content: text });
};
const system = body.system;
if (typeof system === 'string') {
appendMessage('system', system);
} else if (Array.isArray(system)) {
const merged = system.map((item) => parseClaudeMessageContent(item)).filter((item) => item.length > 0).join('\n\n');
if (merged) appendMessage('system', merged);
}
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
for (const message of rawMessages) {
if (!isRecord(message)) continue;
const role = typeof message.role === 'string' ? message.role : 'user';
const mappedRole = role === 'assistant' || role === 'system' ? role : 'user';
appendMessage(mappedRole, message.content);
}
const payload: Record<string, unknown> = {
model,
stream,
messages,
};
const temperature = pickFiniteNumber(body.temperature);
if (temperature !== undefined) payload.temperature = temperature;
const topP = pickFiniteNumber(body.top_p);
if (topP !== undefined) payload.top_p = topP;
const maxTokens = pickFiniteNumber(body.max_tokens);
if (maxTokens !== undefined) {
payload.max_tokens = maxTokens;
} else {
payload.max_tokens = 4096;
}
if (Array.isArray(body.stop_sequences) && body.stop_sequences.length > 0) {
payload.stop = body.stop_sequences;
}
if (body.tools !== undefined) payload.tools = body.tools;
if (body.tool_choice !== undefined) payload.tool_choice = body.tool_choice;
return { model, stream, messages, payload };
}
export function parseDownstreamChatRequest(
body: unknown,
format: DownstreamFormat,
): { value?: ParsedDownstreamChatRequest; error?: { statusCode: number; payload: unknown } } {
const raw = isRecord(body) ? body : {};
if (format === 'claude') {
const converted = convertClaudeRequestToOpenAiBody(raw);
if (!converted.model) {
return {
error: {
statusCode: 400,
payload: { error: { message: 'model is required', type: 'invalid_request_error' } },
},
};
}
if (converted.messages.length <= 0) {
return {
error: {
statusCode: 400,
payload: { error: { message: 'messages is required', type: 'invalid_request_error' } },
},
};
}
return {
value: {
requestedModel: converted.model,
isStream: converted.stream,
upstreamBody: converted.payload,
},
};
}
const model = typeof raw.model === 'string' ? raw.model.trim() : '';
if (!model) {
return {
error: {
statusCode: 400,
payload: { error: { message: 'model is required', type: 'invalid_request_error' } },
},
};
}
return {
value: {
requestedModel: model,
isStream: raw.stream === true,
upstreamBody: raw,
},
};
}
export function normalizeUpstreamFinalResponse(
payload: unknown,
fallbackModel: string,
fallbackText = '',
): NormalizedFinalResponse {
const now = Math.floor(Date.now() / 1000);
const fallbackId = `chatcmpl-meta-${Date.now()}`;
if (isRecord(payload) && Array.isArray(payload.choices)) {
const choice = payload.choices[0] ?? {};
const content = extractAssistantContent(choice) || extractAssistantContent(payload);
const reasoning = extractAssistantReasoning(choice) || extractAssistantReasoning(payload);
return {
id: isNonEmptyString(payload.id) ? payload.id : fallbackId,
model: isNonEmptyString(payload.model) ? payload.model : fallbackModel,
created: ensureIntegerTimestamp(payload.created, now),
content: content || fallbackText,
reasoningContent: reasoning,
finishReason: normalizeStopReason(choice?.finish_reason ?? payload.stop_reason) || 'stop',
};
}
if (isRecord(payload) && typeof payload.type === 'string' && payload.type === 'message') {
return {
id: isNonEmptyString(payload.id) ? payload.id : fallbackId,
model: isNonEmptyString(payload.model) ? payload.model : fallbackModel,
created: now,
content: parseClaudeMessageContent(payload.content) || fallbackText,
reasoningContent: extractTextAndReasoning(payload.content).reasoning,
finishReason: normalizeStopReason(payload.stop_reason) || 'stop',
};
}
if (isRecord(payload) && Array.isArray(payload.candidates)) {
const candidate = payload.candidates[0] || {};
const parsedCandidate = extractTextAndReasoning(candidate?.content?.parts || candidate?.content);
return {
id: isNonEmptyString((payload as any).responseId) ? (payload as any).responseId : fallbackId,
model: isNonEmptyString((payload as any).modelVersion)
? (payload as any).modelVersion
: fallbackModel,
created: now,
content: parsedCandidate.content || fallbackText,
reasoningContent: parsedCandidate.reasoning,
finishReason: normalizeStopReason(candidate?.finishReason || (payload as any).finishReason) || 'stop',
};
}
if (typeof payload === 'string' && payload.trim()) {
return {
id: fallbackId,
model: fallbackModel,
created: now,
content: payload,
reasoningContent: '',
finishReason: 'stop',
};
}
return {
id: fallbackId,
model: fallbackModel,
created: now,
content: fallbackText,
reasoningContent: '',
finishReason: 'stop',
};
}
export function normalizeUpstreamStreamEvent(
payload: unknown,
context: StreamTransformContext,
fallbackModel: string,
): NormalizedStreamEvent {
if (!isRecord(payload)) return {};
if (Array.isArray(payload.choices)) {
if (isNonEmptyString(payload.id)) context.id = payload.id;
if (isNonEmptyString(payload.model)) context.model = payload.model;
context.created = ensureIntegerTimestamp(payload.created, context.created);
const choice = payload.choices[0] ?? {};
const delta = isRecord(choice?.delta) ? choice.delta : {};
const deltaParsed = extractTextAndReasoning(delta.content ?? delta);
const contentDelta =
deltaParsed.content
|| (typeof choice?.message?.content === 'string' ? choice.message.content : '')
|| '';
const reasoningDelta =
(typeof (delta as any).reasoning_content === 'string' ? (delta as any).reasoning_content : '')
|| (typeof (delta as any).reasoning === 'string' ? (delta as any).reasoning : '')
|| deltaParsed.reasoning
|| '';
return {
role: (delta as any).role === 'assistant' ? 'assistant' : undefined,
contentDelta: contentDelta || undefined,
reasoningDelta: reasoningDelta || undefined,
finishReason: normalizeStopReason(choice?.finish_reason),
};
}
const type = typeof payload.type === 'string' ? payload.type : '';
const message = isRecord(payload.message) ? payload.message : null;
if (message) {
if (isNonEmptyString(message.id)) context.id = message.id;
if (isNonEmptyString(message.model)) context.model = message.model;
}
if (!context.model) context.model = fallbackModel;
if (type === 'message_start') {
return { role: 'assistant' };
}
if (type === 'content_block_start') {
const parsed = extractTextAndReasoning(payload.content_block);
return {
contentDelta: parsed.content || undefined,
reasoningDelta: parsed.reasoning || undefined,
};
}
if (type === 'content_block_delta') {
const delta = isRecord(payload.delta) ? payload.delta : {};
const deltaType = typeof delta.type === 'string' ? delta.type : '';
const parsed = extractTextAndReasoning(delta);
if (deltaType === 'thinking_delta') {
return {
reasoningDelta: parsed.content || parsed.reasoning || undefined,
};
}
return {
contentDelta: parsed.content || undefined,
reasoningDelta: parsed.reasoning || undefined,
};
}
if (type === 'message_delta') {
const delta = isRecord(payload.delta) ? payload.delta : {};
return {
finishReason: normalizeStopReason(delta.stop_reason ?? payload.stop_reason),
};
}
if (type === 'message_stop') {
return { done: true };
}
if (Array.isArray(payload.candidates)) {
const candidate = payload.candidates[0] || {};
const parsed = extractTextAndReasoning((candidate as any).content?.parts || (candidate as any).content);
if (isNonEmptyString((payload as any).modelVersion)) {
context.model = (payload as any).modelVersion;
} else if (!context.model) {
context.model = fallbackModel;
}
return {
contentDelta: parsed.content || undefined,
reasoningDelta: parsed.reasoning || undefined,
finishReason: normalizeStopReason((candidate as any).finishReason || (payload as any).finishReason),
};
}
const fallback = extractTextAndReasoning(payload);
return {
contentDelta: fallback.content || undefined,
reasoningDelta: fallback.reasoning || undefined,
};
}
function buildOpenAiStreamChunk(
context: StreamTransformContext,
event: NormalizedStreamEvent,
): Record<string, unknown> | null {
const delta: Record<string, unknown> = {};
const isInitialAssistantRoleOnlyEvent = (
!context.roleSent
&& event.role === 'assistant'
&& !event.contentDelta
&& !event.reasoningDelta
);
if (!context.roleSent && (event.role === 'assistant' || event.contentDelta || event.reasoningDelta)) {
delta.role = 'assistant';
context.roleSent = true;
} else if (event.role === 'assistant') {
delta.role = 'assistant';
context.roleSent = true;
}
if (event.contentDelta) {
delta.content = event.contentDelta;
}
if (event.reasoningDelta) {
delta.reasoning_content = event.reasoningDelta;
}
// Some OpenAI-compatible clients (e.g. OpenWebUI) expect starter chunk to include empty content.
if (isInitialAssistantRoleOnlyEvent) {
delta.content = '';
}
const finishReason = event.finishReason || null;
const hasDelta = Object.keys(delta).length > 0;
if (!hasDelta && !finishReason) return null;
return {
id: context.id,
object: 'chat.completion.chunk',
created: context.created,
model: context.model,
choices: [{
index: 0,
delta,
finish_reason: finishReason,
}],
};
}
function ensureClaudeStartEvents(
context: StreamTransformContext,
claudeContext: ClaudeDownstreamContext,
): string[] {
if (claudeContext.messageStarted) return [];
claudeContext.messageStarted = true;
const payload = {
type: 'message_start',
message: {
id: buildClaudeMessageId(context.id),
type: 'message',
role: 'assistant',
model: context.model,
content: [],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 0,
output_tokens: 0,
},
},
};
return [serializeSse('message_start', payload)];
}
function ensureClaudeContentBlockStart(
claudeContext: ClaudeDownstreamContext,
): string[] {
if (claudeContext.contentBlockStarted) return [];
claudeContext.contentBlockStarted = true;
return [serializeSse('content_block_start', {
type: 'content_block_start',
index: 0,
content_block: {
type: 'text',
text: '',
},
})];
}
function buildClaudeDoneEvents(
context: StreamTransformContext,
claudeContext: ClaudeDownstreamContext,
finishReason?: string | null,
): string[] {
if (claudeContext.doneSent) return [];
const events: string[] = [];
events.push(...ensureClaudeStartEvents(context, claudeContext));
if (claudeContext.contentBlockStarted) {
events.push(serializeSse('content_block_stop', {
type: 'content_block_stop',
index: 0,
}));
claudeContext.contentBlockStarted = false;
}
events.push(serializeSse('message_delta', {
type: 'message_delta',
delta: {
stop_reason: toClaudeStopReason(finishReason),
stop_sequence: null,
},
usage: {
output_tokens: 0,
},
}));
events.push(serializeSse('message_stop', { type: 'message_stop' }));
claudeContext.doneSent = true;
return events;
}
export function serializeNormalizedStreamEvent(
downstreamFormat: DownstreamFormat,
event: NormalizedStreamEvent,
context: StreamTransformContext,
claudeContext: ClaudeDownstreamContext,
): string[] {
if (downstreamFormat === 'openai') {
const chunk = buildOpenAiStreamChunk(context, event);
return chunk ? [serializeSse('', chunk)] : [];
}
if (event.done || event.finishReason) {
return buildClaudeDoneEvents(context, claudeContext, event.finishReason);
}
const events: string[] = [];
if (event.role === 'assistant' || event.contentDelta || event.reasoningDelta) {
events.push(...ensureClaudeStartEvents(context, claudeContext));
}
const mergedText = joinNonEmpty([
event.reasoningDelta || '',
event.contentDelta || '',
]);
if (mergedText) {
events.push(...ensureClaudeContentBlockStart(claudeContext));
events.push(serializeSse('content_block_delta', {
type: 'content_block_delta',
index: 0,
delta: {
type: 'text_delta',
text: mergedText,
},
}));
}
return events;
}
export function serializeStreamDone(
downstreamFormat: DownstreamFormat,
context: StreamTransformContext,
claudeContext: ClaudeDownstreamContext,
): string[] {
if (context.doneSent) return [];
context.doneSent = true;
if (downstreamFormat === 'openai') {
return [serializeSse('', '[DONE]')];
}
return buildClaudeDoneEvents(context, claudeContext, 'stop');
}
export function serializeFinalResponse(
downstreamFormat: DownstreamFormat,
normalized: NormalizedFinalResponse,
usage: { promptTokens: number; completionTokens: number; totalTokens: number },
): Record<string, unknown> {
if (downstreamFormat === 'claude') {
return {
id: buildClaudeMessageId(normalized.id),
type: 'message',
role: 'assistant',
model: normalized.model,
content: [{
type: 'text',
text: normalized.content,
}],
stop_reason: toClaudeStopReason(normalized.finishReason),
stop_sequence: null,
usage: {
input_tokens: usage.promptTokens,
output_tokens: usage.completionTokens,
},
};
}
const message: Record<string, unknown> = {
role: 'assistant',
content: normalized.content,
};
if (normalized.reasoningContent) {
message.reasoning_content = normalized.reasoningContent;
}
return {
id: normalized.id,
object: 'chat.completion',
created: normalized.created,
model: normalized.model,
choices: [{
index: 0,
message,
finish_reason: normalizeStopReason(normalized.finishReason) || 'stop',
}],
usage: {
prompt_tokens: usage.promptTokens,
completion_tokens: usage.completionTokens,
total_tokens: usage.totalTokens,
},
};
}
export function buildSyntheticOpenAiChunks(normalized: NormalizedFinalResponse): Array<Record<string, unknown>> {
const startChunk: Record<string, unknown> = {
id: normalized.id,
object: 'chat.completion.chunk',
created: normalized.created,
model: normalized.model,
choices: [{
index: 0,
delta: normalized.content
? { role: 'assistant', content: normalized.content }
: { role: 'assistant' },
finish_reason: null,
}],
};
if (normalized.reasoningContent) {
(startChunk.choices as any[])[0].delta.reasoning_content = normalized.reasoningContent;
}
const endChunk = {
id: normalized.id,
object: 'chat.completion.chunk',
created: normalized.created,
model: normalized.model,
choices: [{
index: 0,
delta: {},
finish_reason: normalizeStopReason(normalized.finishReason) || 'stop',
}],
};
return [startChunk, endChunk];
}
export function pullSseEventsWithDone(buffer: string): { events: ParsedSseEvent[]; rest: string } {
const normalized = buffer.replace(/\r\n/g, '\n');
const events: ParsedSseEvent[] = [];
let rest = normalized;
while (true) {
const boundary = rest.indexOf('\n\n');
if (boundary < 0) break;
const block = rest.slice(0, boundary);
rest = rest.slice(boundary + 2);
if (!block.trim()) continue;
const lines = block.split('\n');
let eventName = '';
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith('event:')) {
eventName = line.slice(6).trim();
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart());
}
}
if (dataLines.length <= 0) continue;
events.push({
event: eventName,
data: dataLines.join('\n').trim(),
});
}
return { events, rest };
}
+256
View File
@@ -0,0 +1,256 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, schema } from '../../db/index.js';
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 { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js';
const MAX_RETRIES = 2;
export async function completionsProxyRoute(app: FastifyInstance) {
app.post('/v1/completions', async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as any;
const requestedModel = body?.model;
if (!requestedModel) {
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
}
const isStream = body.stream === true;
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel);
}
if (!selected) {
await reportProxyAllFailed({
model: requestedModel,
reason: 'No available channels after retries',
});
return reply.code(503).send({
error: { message: 'No available channels for this model', type: 'server_error' },
});
}
excludeChannelIds.push(selected.channel.id);
const targetUrl = `${selected.site.url}/v1/completions`;
const forwardBody = { ...body, model: selected.actualModel };
const startTime = Date.now();
try {
const upstream = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
});
if (!upstream.ok) {
const errText = await upstream.text().catch(() => 'unknown error');
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount);
if (isTokenExpiredError({ status: upstream.status, message: errText })) {
await reportTokenExpired({
accountId: selected.account.id,
username: selected.account.username,
siteName: selected.site.name,
detail: `HTTP ${upstream.status}`,
});
}
if (shouldRetryProxyRequest(upstream.status, errText) && retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: `upstream returned HTTP ${upstream.status}`,
});
return reply.code(upstream.status).send({ error: { message: errText, type: 'upstream_error' } });
}
if (isStream) {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
const reader = upstream.body?.getReader();
if (!reader) {
reply.raw.end();
return;
}
const decoder = new TextDecoder();
let parsedUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
let sseBuffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
reply.raw.write(chunk);
sseBuffer += chunk;
const pulled = pullSseDataEvents(sseBuffer);
sseBuffer = pulled.rest;
for (const eventPayload of pulled.events) {
try {
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(JSON.parse(eventPayload)));
} catch {}
}
}
if (sseBuffer.trim().length > 0) {
const pulled = pullSseDataEvents(`${sseBuffer}\n\n`);
for (const eventPayload of pulled.events) {
try {
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(JSON.parse(eventPayload)));
} catch {}
}
}
} finally {
reader.releaseLock();
reply.raw.end();
}
const latency = Date.now() - startTime;
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
tokenValue: selected.tokenValue,
tokenName: selected.tokenName,
modelName: selected.actualModel || requestedModel,
requestStartedAtMs: startTime,
requestEndedAtMs: startTime + latency,
localLatencyMs: latency,
usage: {
promptTokens: parsedUsage.promptTokens,
completionTokens: parsedUsage.completionTokens,
totalTokens: parsedUsage.totalTokens,
},
});
let estimatedCost = await estimateProxyCost({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
);
return;
}
const data = await upstream.json() as any;
const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(data);
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
tokenValue: selected.tokenValue,
tokenName: selected.tokenName,
modelName: selected.actualModel || requestedModel,
requestStartedAtMs: startTime,
requestEndedAtMs: startTime + latency,
localLatencyMs: latency,
usage: {
promptTokens: parsedUsage.promptTokens,
completionTokens: parsedUsage.completionTokens,
totalTokens: parsedUsage.totalTokens,
},
});
let estimatedCost = await estimateProxyCost({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
);
return reply.send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: err.message || 'network failure',
});
return reply.code(502).send({
error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' },
});
}
}
});
}
function logProxy(
selected: any,
modelRequested: string,
status: string,
httpStatus: number,
latencyMs: number,
errorMessage: string | null,
retryCount: number,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
) {
try {
db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
modelRequested,
modelActual: selected.actualModel,
status,
httpStatus,
latencyMs,
promptTokens,
completionTokens,
totalTokens,
estimatedCost,
errorMessage,
retryCount,
}).run();
} catch {}
}
+172
View File
@@ -0,0 +1,172 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, schema } from '../../db/index.js';
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 { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
const MAX_RETRIES = 2;
export async function embeddingsProxyRoute(app: FastifyInstance) {
app.post('/v1/embeddings', async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as any;
const requestedModel = body?.model;
if (!requestedModel) {
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
}
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel);
}
if (!selected) {
await reportProxyAllFailed({
model: requestedModel,
reason: 'No available channels after retries',
});
return reply.code(503).send({ error: { message: 'No available channels', type: 'server_error' } });
}
excludeChannelIds.push(selected.channel.id);
const targetUrl = `${selected.site.url}/v1/embeddings`;
const forwardBody = { ...body, model: selected.actualModel };
const startTime = Date.now();
try {
const upstream = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
});
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
username: selected.account.username,
siteName: selected.site.name,
detail: `HTTP ${upstream.status}`,
});
}
if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: `upstream returned HTTP ${upstream.status}`,
});
return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } });
}
let data: any = {};
try { data = JSON.parse(text); } catch { data = {}; }
const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(data);
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
tokenValue: selected.tokenValue,
tokenName: selected.tokenName,
modelName: selected.actualModel || requestedModel,
requestStartedAtMs: startTime,
requestEndedAtMs: startTime + latency,
localLatencyMs: latency,
usage: {
promptTokens: parsedUsage.promptTokens,
completionTokens: parsedUsage.completionTokens,
totalTokens: parsedUsage.totalTokens,
},
});
let estimatedCost = await estimateProxyCost({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens,
});
if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) {
estimatedCost = resolvedUsage.estimatedCostFromQuota;
}
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
logProxy(
selected, requestedModel, 'success', upstream.status, latency, null, retryCount,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
);
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);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: err.message || 'network failure',
});
return reply.code(502).send({ error: { message: err.message, type: 'upstream_error' } });
}
}
});
}
function logProxy(
selected: any,
modelRequested: string,
status: string,
httpStatus: number,
latencyMs: number,
errorMessage: string | null,
retryCount: number,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
) {
try {
db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
modelRequested,
modelActual: selected.actualModel,
status,
httpStatus,
latencyMs,
promptTokens,
completionTokens,
totalTokens,
estimatedCost,
errorMessage,
retryCount,
}).run();
} catch {}
}
+142
View File
@@ -0,0 +1,142 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, schema } from '../../db/index.js';
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 { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
const MAX_RETRIES = 2;
export async function imagesProxyRoute(app: FastifyInstance) {
app.post('/v1/images/generations', async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as any;
const requestedModel = body?.model || 'gpt-image-1';
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel);
}
if (!selected) {
await reportProxyAllFailed({
model: requestedModel,
reason: 'No available channels after retries',
});
return reply.code(503).send({
error: { message: 'No available channels for this model', type: 'server_error' },
});
}
excludeChannelIds.push(selected.channel.id);
const targetUrl = `${selected.site.url}/v1/images/generations`;
const forwardBody = { ...body, model: selected.actualModel };
const startTime = Date.now();
try {
const upstream = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
});
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
username: selected.account.username,
siteName: selected.site.name,
detail: `HTTP ${upstream.status}`,
});
}
if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: `upstream returned HTTP ${upstream.status}`,
});
return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } });
}
let data: any = {};
try { data = JSON.parse(text); } catch { data = { data: [] }; }
const latency = Date.now() - startTime;
const estimatedCost = await estimateProxyCost({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost);
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);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: err.message || 'network failure',
});
return reply.code(502).send({
error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' },
});
}
}
});
}
function logProxy(
selected: any,
modelRequested: string,
status: string,
httpStatus: number,
latencyMs: number,
errorMessage: string | null,
retryCount: number,
estimatedCost = 0,
) {
try {
db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
modelRequested,
modelActual: selected.actualModel,
status,
httpStatus,
latencyMs,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
estimatedCost,
errorMessage,
retryCount,
}).run();
} catch {}
}
+57
View File
@@ -0,0 +1,57 @@
import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, eq } from 'drizzle-orm';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
export async function modelsProxyRoute(app: FastifyInstance) {
app.get('/v1/models', async (request) => {
const readModels = () => {
const rows = db.select({ modelName: schema.modelAvailability.modelName })
.from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.modelAvailability.available, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
return Array.from(new Set(rows.map((r) => r.modelName))).sort();
};
let models = readModels();
if (models.length === 0) {
await refreshModelsAndRebuildRoutes();
models = readModels();
}
const wantsClaudeFormat = typeof request.headers['anthropic-version'] === 'string'
|| typeof request.headers['x-api-key'] === 'string';
if (wantsClaudeFormat) {
const data = models.map((id) => ({
id,
type: 'model',
display_name: id,
created_at: new Date().toISOString(),
}));
return {
data,
first_id: data[0]?.id || null,
last_id: data[data.length - 1]?.id || null,
has_more: false,
};
}
return {
object: 'list',
data: models.map(id => ({
id,
object: 'model',
created: Math.floor(Date.now() / 1000),
owned_by: 'metapi',
})),
};
});
}
+21
View File
@@ -0,0 +1,21 @@
import { FastifyInstance } from 'fastify';
import { proxyAuthMiddleware } from '../../middleware/auth.js';
import { chatProxyRoute, claudeMessagesProxyRoute } from './chat.js';
import { modelsProxyRoute } from './models.js';
import { embeddingsProxyRoute } from './embeddings.js';
import { completionsProxyRoute } from './completions.js';
import { imagesProxyRoute } from './images.js';
export async function proxyRoutes(app: FastifyInstance) {
// Auth middleware for all /v1 routes
app.addHook('onRequest', async (request, reply) => {
await proxyAuthMiddleware(request, reply);
});
await app.register(chatProxyRoute);
await app.register(claudeMessagesProxyRoute);
await app.register(completionsProxyRoute);
await app.register(modelsProxyRoute);
await app.register(embeddingsProxyRoute);
await app.register(imagesProxyRoute);
}