test:Add database migration service with normalization and masking functions

- Implemented `normalizeMigrationInput` to standardize database migration input for sqlite, mysql, and postgres.
- Added `maskConnectionString` to obscure sensitive credentials in connection strings.
- Created comprehensive tests for migration input normalization and connection string masking.
- Established database schema creation logic for multiple dialects (sqlite, mysql, postgres).
- Developed functions for managing database transactions, including begin, commit, and rollback.
- Implemented data insertion logic from backup snapshots into the target database.
This commit is contained in:
cita
2026-03-05 17:38:52 +08:00
parent 0168592c95
commit 283c052fa5
79 changed files with 3410 additions and 1111 deletions
+4 -4
View File
@@ -144,7 +144,7 @@ function applyRuntimeHealthToExtraConfig(extraConfig: string | null | undefined,
});
}
export function setAccountRuntimeHealth(
export async function setAccountRuntimeHealth(
accountId: number,
input: {
state: RuntimeHealthState;
@@ -152,16 +152,16 @@ export function setAccountRuntimeHealth(
source?: string | null;
checkedAt?: string | null;
},
): RuntimeHealthInfo | null {
): Promise<RuntimeHealthInfo | null> {
try {
const query = db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)) as any;
const account = typeof query?.get === 'function' ? query.get() : null;
const account = typeof query?.get === 'function' ? await query.get() : null;
if (!account) return null;
const health = buildRuntimeHealthPatch(input);
const nextExtraConfig = applyRuntimeHealthToExtraConfig(account.extraConfig, health);
db.update(schema.accounts)
await db.update(schema.accounts)
.set({
extraConfig: nextExtraConfig,
updatedAt: new Date().toISOString(),
+41 -34
View File
@@ -59,15 +59,15 @@ function normalizeTokenGroup(value: string | null | undefined, tokenName?: strin
return name;
}
function updateAccountApiToken(accountId: number, tokenValue: string | null) {
db.update(schema.accounts)
async function updateAccountApiToken(accountId: number, tokenValue: string | null) {
await db.update(schema.accounts)
.set({ apiToken: tokenValue || null, updatedAt: new Date().toISOString() })
.where(eq(schema.accounts.id, accountId))
.run();
}
export function getPreferredAccountToken(accountId: number) {
const tokens = db.select()
export async function getPreferredAccountToken(accountId: number) {
const tokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true)))
.all();
@@ -78,24 +78,24 @@ export function getPreferredAccountToken(accountId: number) {
return preferred;
}
export function ensureDefaultTokenForAccount(
export async function ensureDefaultTokenForAccount(
accountId: number,
tokenValue: string,
options?: { name?: string; source?: string; enabled?: boolean; tokenGroup?: string | null },
): number | null {
): Promise<number | null> {
const normalizedToken = normalizeTokenValue(tokenValue);
if (!normalizedToken) return null;
const tokenGroup = normalizeTokenGroup(options?.tokenGroup, options?.name) || 'default';
const now = new Date().toISOString();
const tokens = db.select()
const tokens = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, accountId))
.all();
let target = tokens.find((t) => t.token === normalizedToken) || null;
if (!target) {
target = db.insert(schema.accountTokens)
const inserted = await db.insert(schema.accountTokens)
.values({
accountId,
name: normalizeTokenName(options?.name, tokens.length + 1),
@@ -107,10 +107,14 @@ export function ensureDefaultTokenForAccount(
createdAt: now,
updatedAt: now,
})
.returning()
.get();
.run();
const insertedId = Number(inserted.lastInsertRowid || 0);
target = insertedId > 0
? (await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, insertedId)).get()) ?? null
: null;
if (!target) return null;
} else {
db.update(schema.accountTokens)
await db.update(schema.accountTokens)
.set({
name: options?.name ? normalizeTokenName(options.name) : target.name,
tokenGroup,
@@ -123,66 +127,66 @@ export function ensureDefaultTokenForAccount(
.run();
}
db.update(schema.accountTokens)
await db.update(schema.accountTokens)
.set({ isDefault: false, updatedAt: now })
.where(and(eq(schema.accountTokens.accountId, accountId), ne(schema.accountTokens.id, target.id)))
.run();
updateAccountApiToken(accountId, normalizedToken);
await updateAccountApiToken(accountId, normalizedToken);
return target.id;
}
export function setDefaultToken(tokenId: number): boolean {
const target = db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
export async function setDefaultToken(tokenId: number): Promise<boolean> {
const target = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
if (!target) return false;
const now = new Date().toISOString();
db.update(schema.accountTokens)
await db.update(schema.accountTokens)
.set({ isDefault: false, updatedAt: now })
.where(eq(schema.accountTokens.accountId, target.accountId))
.run();
db.update(schema.accountTokens)
await db.update(schema.accountTokens)
.set({ isDefault: true, enabled: true, updatedAt: now })
.where(eq(schema.accountTokens.id, tokenId))
.run();
updateAccountApiToken(target.accountId, target.token);
await updateAccountApiToken(target.accountId, target.token);
return true;
}
export function repairDefaultToken(accountId: number) {
const tokens = db.select()
export async function repairDefaultToken(accountId: number) {
const tokens = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, accountId))
.all();
const enabled = tokens.filter((t) => t.enabled);
if (enabled.length === 0) {
updateAccountApiToken(accountId, null);
await updateAccountApiToken(accountId, null);
return null;
}
const currentDefault = enabled.find((t) => t.isDefault) || enabled[0];
const now = new Date().toISOString();
db.update(schema.accountTokens)
await db.update(schema.accountTokens)
.set({ isDefault: false, updatedAt: now })
.where(eq(schema.accountTokens.accountId, accountId))
.run();
db.update(schema.accountTokens)
await db.update(schema.accountTokens)
.set({ isDefault: true, enabled: true, updatedAt: now })
.where(eq(schema.accountTokens.id, currentDefault.id))
.run();
updateAccountApiToken(accountId, currentDefault.token);
await updateAccountApiToken(accountId, currentDefault.token);
return currentDefault;
}
export function syncTokensFromUpstream(accountId: number, upstreamTokens: UpstreamApiToken[]) {
export async function syncTokensFromUpstream(accountId: number, upstreamTokens: UpstreamApiToken[]) {
const now = new Date().toISOString();
const existing = db.select()
const existing = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, accountId))
.all();
@@ -201,7 +205,7 @@ export function syncTokensFromUpstream(accountId: number, upstreamTokens: Upstre
const byToken = existing.find((row) => row.token === tokenValue);
if (byToken) {
db.update(schema.accountTokens)
await db.update(schema.accountTokens)
.set({
name: tokenName,
tokenGroup,
@@ -220,7 +224,7 @@ export function syncTokensFromUpstream(accountId: number, upstreamTokens: Upstre
continue;
}
const createdRow = db.insert(schema.accountTokens)
const inserted = await db.insert(schema.accountTokens)
.values({
accountId,
name: tokenName,
@@ -232,15 +236,18 @@ export function syncTokensFromUpstream(accountId: number, upstreamTokens: Upstre
createdAt: now,
updatedAt: now,
})
.returning()
.get();
.run();
const insertedId = Number(inserted.lastInsertRowid || 0);
if (insertedId <= 0) continue;
const createdRow = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, insertedId)).get();
if (!createdRow) continue;
existing.push(createdRow);
created++;
index++;
}
const repaired = repairDefaultToken(accountId);
const repaired = await repairDefaultToken(accountId);
return {
created,
@@ -250,15 +257,15 @@ export function syncTokensFromUpstream(accountId: number, upstreamTokens: Upstre
};
}
export function listTokensWithRelations(accountId?: number) {
export async function listTokensWithRelations(accountId?: number) {
const base = 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));
const rows = accountId
? base.where(eq(schema.accountTokens.accountId, accountId)).all()
: base.all();
? await base.where(eq(schema.accountTokens.accountId, accountId)).all()
: await base.all();
return rows.map((row) => {
const { token, ...tokenMeta } = row.account_tokens;
+3 -3
View File
@@ -15,7 +15,7 @@ export async function reportTokenExpired(params: {
const detailText = params.detail ? appendSessionTokenRebindHint(params.detail) : '';
const detail = detailText ? ` (${detailText})` : '';
db.insert(schema.events).values({
await db.insert(schema.events).values({
type: 'token',
title: 'Token 已失效',
message: `${accountLabel} @ ${siteLabel} 的 Token 无效或已过期${detail}`,
@@ -24,7 +24,7 @@ export async function reportTokenExpired(params: {
relatedType: 'account',
}).run();
db.update(schema.accounts).set({
await db.update(schema.accounts).set({
status: 'expired',
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, params.accountId)).run();
@@ -43,7 +43,7 @@ export async function reportTokenExpired(params: {
}
export async function reportProxyAllFailed(params: { model: string; reason: string }) {
db.insert(schema.events).values({
await db.insert(schema.events).values({
type: 'proxy',
title: '代理全部失败',
message: `模型=${params.model}, 原因=${params.reason}`,
+2 -2
View File
@@ -80,9 +80,9 @@ function setTaskStatus(task: BackgroundTask, patch: Partial<BackgroundTask>) {
return next;
}
function appendTaskEvent(level: 'info' | 'warning' | 'error', title: string, message: string, taskId: string) {
async function appendTaskEvent(level: 'info' | 'warning' | 'error', title: string, message: string, taskId: string) {
try {
db.insert(schema.events).values({
await db.insert(schema.events).values({
type: 'status',
title,
message,
+28 -28
View File
@@ -26,26 +26,26 @@ describe('backupService', () => {
backupService = serviceModule;
});
beforeEach(() => {
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
db.delete(schema.settings).run();
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
await db.delete(schema.settings).run();
});
afterAll(() => {
delete process.env.DATA_DIR;
});
it('preserves extended fields in full backup import/export roundtrip', () => {
it('preserves extended fields in full backup import/export roundtrip', async () => {
const now = new Date().toISOString();
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'roundtrip-site',
url: 'https://roundtrip.example.com',
platform: 'new-api',
@@ -58,7 +58,7 @@ describe('backupService', () => {
updatedAt: now,
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'roundtrip-user',
accessToken: 'session-token',
@@ -77,7 +77,7 @@ describe('backupService', () => {
updatedAt: now,
}).returning().get();
const accountToken = db.insert(schema.accountTokens).values({
const accountToken = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'sk-roundtrip-token',
@@ -88,7 +88,7 @@ describe('backupService', () => {
updatedAt: now,
}).returning().get();
const route = db.insert(schema.tokenRoutes).values({
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-*',
displayName: 'gpt-route',
displayIcon: 'icon-gpt',
@@ -98,7 +98,7 @@ describe('backupService', () => {
updatedAt: now,
}).returning().get();
db.insert(schema.routeChannels).values({
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: accountToken.id,
@@ -116,16 +116,16 @@ describe('backupService', () => {
cooldownUntil: now,
}).run();
const exported = backupService.exportBackup('all');
const result = backupService.importBackup(exported as unknown as Record<string, unknown>);
const exported = await backupService.exportBackup('all');
const result = await backupService.importBackup(exported as unknown as Record<string, unknown>);
expect(result.allImported).toBe(true);
expect(result.sections.accounts).toBe(true);
const restoredSite = db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get();
const restoredAccount = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const restoredRoute = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get();
const restoredChannel = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.routeId, route.id)).get();
const restoredSite = await db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get();
const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const restoredRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get();
const restoredChannel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.routeId, route.id)).get();
expect(restoredSite?.proxyUrl).toBe('http://127.0.0.1:8080');
expect(restoredSite?.isPinned).toBe(true);
@@ -140,7 +140,7 @@ describe('backupService', () => {
expect(restoredChannel?.sourceModel).toBe('gpt-4o');
});
it('imports ALL-API-Hub style payload with accounts and preferences', () => {
it('imports ALL-API-Hub style payload with accounts and preferences', async () => {
const payload = {
timestamp: Date.now(),
accounts: {
@@ -180,15 +180,15 @@ describe('backupService', () => {
},
} as Record<string, unknown>;
const result = backupService.importBackup(payload);
const result = await backupService.importBackup(payload);
expect(result.allImported).toBe(true);
expect(result.sections.accounts).toBe(true);
expect(result.sections.preferences).toBe(true);
expect(result.appliedSettings.length).toBeGreaterThan(0);
const sites = db.select().from(schema.sites).all();
const accounts = db.select().from(schema.accounts).all();
const settings = db.select().from(schema.settings).all();
const sites = await db.select().from(schema.sites).all();
const accounts = await db.select().from(schema.accounts).all();
const settings = await db.select().from(schema.settings).all();
expect(sites.length).toBe(1);
expect(accounts.length).toBe(1);
+35 -35
View File
@@ -314,12 +314,12 @@ function isSettingValueAcceptable(key: string, value: unknown): boolean {
return true;
}
function exportAccountsSection(): AccountsBackupSection {
const sites = db.select().from(schema.sites).orderBy(asc(schema.sites.id)).all();
const accounts = db.select().from(schema.accounts).orderBy(asc(schema.accounts.id)).all();
const accountTokens = db.select().from(schema.accountTokens).orderBy(asc(schema.accountTokens.id)).all();
const tokenRoutes = db.select().from(schema.tokenRoutes).orderBy(asc(schema.tokenRoutes.id)).all();
const routeChannels = db.select().from(schema.routeChannels).orderBy(asc(schema.routeChannels.id)).all();
async function exportAccountsSection(): Promise<AccountsBackupSection> {
const sites = await db.select().from(schema.sites).orderBy(asc(schema.sites.id)).all();
const accounts = await db.select().from(schema.accounts).orderBy(asc(schema.accounts.id)).all();
const accountTokens = await db.select().from(schema.accountTokens).orderBy(asc(schema.accountTokens.id)).all();
const tokenRoutes = await db.select().from(schema.tokenRoutes).orderBy(asc(schema.tokenRoutes.id)).all();
const routeChannels = await db.select().from(schema.routeChannels).orderBy(asc(schema.routeChannels.id)).all();
return {
sites,
@@ -330,8 +330,8 @@ function exportAccountsSection(): AccountsBackupSection {
};
}
function exportPreferencesSection(): PreferencesBackupSection {
const settings = db.select().from(schema.settings).all()
async function exportPreferencesSection(): Promise<PreferencesBackupSection> {
const settings = (await db.select().from(schema.settings).all())
.filter((row) => !EXCLUDED_SETTING_KEYS.has(row.key))
.map((row) => ({
key: row.key,
@@ -341,14 +341,14 @@ function exportPreferencesSection(): PreferencesBackupSection {
return { settings };
}
export function exportBackup(type: BackupExportType): BackupV2 {
export async function exportBackup(type: BackupExportType): Promise<BackupV2> {
const now = Date.now();
if (type === 'accounts') {
return {
version: BACKUP_VERSION,
timestamp: now,
type: 'accounts',
accounts: exportAccountsSection(),
accounts: await exportAccountsSection(),
};
}
@@ -357,15 +357,15 @@ export function exportBackup(type: BackupExportType): BackupV2 {
version: BACKUP_VERSION,
timestamp: now,
type: 'preferences',
preferences: exportPreferencesSection(),
preferences: await exportPreferencesSection(),
};
}
return {
version: BACKUP_VERSION,
timestamp: now,
accounts: exportAccountsSection(),
preferences: exportPreferencesSection(),
accounts: await exportAccountsSection(),
preferences: await exportPreferencesSection(),
};
}
@@ -446,20 +446,20 @@ function detectPreferencesSection(data: RawBackupData): PreferencesBackupSection
return null;
}
function importAccountsSection(section: AccountsBackupSection) {
db.transaction((tx) => {
tx.delete(schema.routeChannels).run();
tx.delete(schema.tokenRoutes).run();
tx.delete(schema.tokenModelAvailability).run();
tx.delete(schema.modelAvailability).run();
tx.delete(schema.proxyLogs).run();
tx.delete(schema.checkinLogs).run();
tx.delete(schema.accountTokens).run();
tx.delete(schema.accounts).run();
tx.delete(schema.sites).run();
async function importAccountsSection(section: AccountsBackupSection): Promise<void> {
await db.transaction(async (tx) => {
await tx.delete(schema.routeChannels).run();
await tx.delete(schema.tokenRoutes).run();
await tx.delete(schema.tokenModelAvailability).run();
await tx.delete(schema.modelAvailability).run();
await tx.delete(schema.proxyLogs).run();
await tx.delete(schema.checkinLogs).run();
await tx.delete(schema.accountTokens).run();
await tx.delete(schema.accounts).run();
await tx.delete(schema.sites).run();
for (const row of section.sites) {
tx.insert(schema.sites).values({
await tx.insert(schema.sites).values({
id: row.id,
name: row.name,
url: row.url,
@@ -476,7 +476,7 @@ function importAccountsSection(section: AccountsBackupSection) {
}
for (const row of section.accounts) {
tx.insert(schema.accounts).values({
await tx.insert(schema.accounts).values({
id: row.id,
siteId: row.siteId,
username: row.username,
@@ -500,7 +500,7 @@ function importAccountsSection(section: AccountsBackupSection) {
}
for (const row of section.accountTokens) {
tx.insert(schema.accountTokens).values({
await tx.insert(schema.accountTokens).values({
id: row.id,
accountId: row.accountId,
name: row.name,
@@ -515,7 +515,7 @@ function importAccountsSection(section: AccountsBackupSection) {
}
for (const row of section.tokenRoutes) {
tx.insert(schema.tokenRoutes).values({
await tx.insert(schema.tokenRoutes).values({
id: row.id,
modelPattern: row.modelPattern,
displayName: row.displayName ?? null,
@@ -528,7 +528,7 @@ function importAccountsSection(section: AccountsBackupSection) {
}
for (const row of section.routeChannels) {
tx.insert(schema.routeChannels).values({
await tx.insert(schema.routeChannels).values({
id: row.id,
routeId: row.routeId,
accountId: row.accountId,
@@ -550,14 +550,14 @@ function importAccountsSection(section: AccountsBackupSection) {
});
}
function importPreferencesSection(section: PreferencesBackupSection): Array<{ key: string; value: unknown }> {
async function importPreferencesSection(section: PreferencesBackupSection): Promise<Array<{ key: string; value: unknown }>> {
const applied: Array<{ key: string; value: unknown }> = [];
db.transaction((tx) => {
await db.transaction(async (tx) => {
for (const row of section.settings) {
if (!isSettingValueAcceptable(row.key, row.value)) continue;
tx.insert(schema.settings).values({
await tx.insert(schema.settings).values({
key: row.key,
value: stringifySettingValue(row.value),
}).onConflictDoUpdate({
@@ -571,7 +571,7 @@ function importPreferencesSection(section: PreferencesBackupSection): Array<{ ke
return applied;
}
export function importBackup(data: RawBackupData): BackupImportResult {
export async function importBackup(data: RawBackupData): Promise<BackupImportResult> {
if (!isRecord(data)) {
throw new Error('导入数据格式错误:必须为 JSON 对象');
}
@@ -599,7 +599,7 @@ export function importBackup(data: RawBackupData): BackupImportResult {
if (!accountsSection) {
throw new Error('导入数据格式错误:账号数据结构不正确');
}
importAccountsSection(accountsSection);
await importAccountsSection(accountsSection);
accountsImported = true;
}
@@ -607,7 +607,7 @@ export function importBackup(data: RawBackupData): BackupImportResult {
if (!preferencesSection) {
throw new Error('导入数据格式错误:设置数据结构不正确');
}
appliedSettings = importPreferencesSection(preferencesSection);
appliedSettings = await importPreferencesSection(preferencesSection);
preferencesImported = true;
}
+5 -5
View File
@@ -217,7 +217,7 @@ async function refreshSub2ApiManagedSession(params: {
tokenExpiresAt: refreshed.tokenExpiresAt,
},
});
db.update(schema.accounts)
await db.update(schema.accounts)
.set({
accessToken: refreshed.accessToken,
extraConfig: nextExtraConfig,
@@ -320,7 +320,7 @@ async function tryAutoRelogin(account: any, site: any): Promise<string | null> {
const loginResult = await adapter.login(site.url, relogin.username, password);
if (!loginResult.success || !loginResult.accessToken) return null;
db.update(schema.accounts)
await db.update(schema.accounts)
.set({
accessToken: loginResult.accessToken,
status: account.status === 'expired' ? 'active' : account.status,
@@ -333,7 +333,7 @@ async function tryAutoRelogin(account: any, site: any): Promise<string | null> {
}
export async function refreshBalance(accountId: number) {
const rows = db
const rows = await db
.select()
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
@@ -485,7 +485,7 @@ export async function refreshBalance(accountId: number) {
updates.extraConfig = nextExtraConfig;
}
db.update(schema.accounts)
await db.update(schema.accounts)
.set(updates)
.where(eq(schema.accounts.id, accountId))
.run();
@@ -504,7 +504,7 @@ export async function refreshBalance(accountId: number) {
}
export async function refreshAllBalances() {
const rows = db
const rows = await db
.select()
.from(schema.accounts)
.where(eq(schema.accounts.status, 'active'))
+7 -7
View File
@@ -14,9 +14,9 @@ let dailySummaryTask: cron.ScheduledTask | null = null;
const DAILY_SUMMARY_DEFAULT_CRON = '58 23 * * *';
function resolveCronSetting(settingKey: string, fallback: string): string {
async function resolveCronSetting(settingKey: string, fallback: string): Promise<string> {
try {
const row = db.select().from(schema.settings).where(eq(schema.settings.key, settingKey)).get();
const row = await db.select().from(schema.settings).where(eq(schema.settings.key, settingKey)).get();
if (row?.value) {
const parsed = JSON.parse(row.value);
if (typeof parsed === 'string' && cron.validate(parsed)) {
@@ -58,7 +58,7 @@ function createDailySummaryTask(cronExpr: string) {
return cron.schedule(cronExpr, async () => {
console.log(`[Scheduler] Sending daily summary at ${new Date().toISOString()}`);
try {
const metrics = collectDailySummaryMetrics();
const metrics = await collectDailySummaryMetrics();
const { title, message } = buildDailySummaryNotification(metrics);
await sendNotification(title, message, 'info', {
bypassThrottle: true,
@@ -72,10 +72,10 @@ function createDailySummaryTask(cronExpr: string) {
});
}
export function startScheduler() {
const activeCheckinCron = resolveCronSetting('checkin_cron', config.checkinCron);
const activeBalanceCron = resolveCronSetting('balance_refresh_cron', config.balanceRefreshCron);
const activeDailySummaryCron = resolveCronSetting('daily_summary_cron', DAILY_SUMMARY_DEFAULT_CRON);
export async function startScheduler() {
const activeCheckinCron = await resolveCronSetting('checkin_cron', config.checkinCron);
const activeBalanceCron = await resolveCronSetting('balance_refresh_cron', config.balanceRefreshCron);
const activeDailySummaryCron = await resolveCronSetting('daily_summary_cron', DAILY_SUMMARY_DEFAULT_CRON);
config.checkinCron = activeCheckinCron;
config.balanceRefreshCron = activeBalanceCron;
+8 -8
View File
@@ -102,7 +102,7 @@ async function tryAutoRelogin(account: any, site: any): Promise<string | null> {
const result = await adapter.login(site.url, relogin.username, password);
if (!result.success || !result.accessToken) return null;
db.update(schema.accounts)
await db.update(schema.accounts)
.set({
accessToken: result.accessToken,
updatedAt: new Date().toISOString(),
@@ -115,7 +115,7 @@ async function tryAutoRelogin(account: any, site: any): Promise<string | null> {
}
export async function checkinAccount(accountId: number, options?: { skipEvent?: boolean }) {
const rows = db
const rows = await db
.select()
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
@@ -133,14 +133,14 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
reason: '\u7ad9\u70b9\u5df2\u7981\u7528',
source: 'checkin',
});
db.insert(schema.checkinLogs).values({
await db.insert(schema.checkinLogs).values({
accountId: account.id,
status: 'skipped',
message: 'site disabled',
}).run();
if (!options?.skipEvent) {
db.insert(schema.events).values({
await db.insert(schema.events).values({
type: 'checkin',
title: 'checkin skipped',
message: `${account.username || 'ID:' + accountId} @ ${site.name}: site disabled`,
@@ -220,7 +220,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
updates.updatedAt = new Date().toISOString();
}
db.update(schema.accounts)
await db.update(schema.accounts)
.set(updates)
.where(eq(schema.accounts.id, accountId))
.run();
@@ -240,7 +240,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
}
}
db.insert(schema.checkinLogs).values({
await db.insert(schema.checkinLogs).values({
accountId: account.id,
status: normalizedStatus,
message: logMessage,
@@ -248,7 +248,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
}).run();
if (!options?.skipEvent) {
db.insert(schema.events).values({
await db.insert(schema.events).values({
type: 'checkin',
title: effectiveSuccess
? (normalizedStatus === 'skipped' ? 'checkin skipped' : 'checkin success')
@@ -302,7 +302,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
}
export async function checkinAll() {
const rows = db
const rows = await db
.select()
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
+4 -4
View File
@@ -27,10 +27,10 @@ function round6(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}
export function collectDailySummaryMetrics(now = new Date()): DailySummaryMetrics {
export async function collectDailySummaryMetrics(now = new Date()): Promise<DailySummaryMetrics> {
const { localDay, startUtc, endUtc } = getLocalDayRangeUtc(now);
const accountRows = db.select().from(schema.accounts)
const accountRows = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.sites.status, 'active'))
.all();
@@ -39,7 +39,7 @@ export function collectDailySummaryMetrics(now = new Date()): DailySummaryMetric
const activeAccounts = accounts.filter((account) => account.status === 'active').length;
const lowBalanceAccounts = accounts.filter((account) => (account.balance || 0) < 1).length;
const todayCheckinRows = db.select().from(schema.checkinLogs)
const todayCheckinRows = await 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(
@@ -67,7 +67,7 @@ export function collectDailySummaryMetrics(now = new Date()): DailySummaryMetric
parsedRewardCountByAccount[accountId] = (parsedRewardCountByAccount[accountId] || 0) + 1;
}
const todayProxyRows = db.select().from(schema.proxyLogs)
const todayProxyRows = await 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(
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { maskConnectionString, normalizeMigrationInput } from './databaseMigrationService.js';
describe('databaseMigrationService', () => {
it('accepts postgres migration input with normalized url', () => {
const normalized = normalizeMigrationInput({
dialect: 'postgres',
connectionString: ' postgres://user:pass@db.example.com:5432/metapi ',
overwrite: true,
});
expect(normalized).toEqual({
dialect: 'postgres',
connectionString: 'postgres://user:pass@db.example.com:5432/metapi',
overwrite: true,
});
});
it('accepts mysql migration input', () => {
const normalized = normalizeMigrationInput({
dialect: 'mysql',
connectionString: 'mysql://root:pass@db.example.com:3306/metapi',
});
expect(normalized.dialect).toBe('mysql');
expect(normalized.overwrite).toBe(true);
});
it('accepts sqlite file migration target path', () => {
const normalized = normalizeMigrationInput({
dialect: 'sqlite',
connectionString: './data/target.db',
overwrite: false,
});
expect(normalized).toEqual({
dialect: 'sqlite',
connectionString: './data/target.db',
overwrite: false,
});
});
it('rejects unknown dialect', () => {
expect(() => normalizeMigrationInput({
dialect: 'oracle',
connectionString: 'oracle://db',
} as any)).toThrow(/鏂硅█|sqlite\/mysql\/postgres/i);
});
it('rejects postgres input when scheme mismatches', () => {
expect(() => normalizeMigrationInput({
dialect: 'postgres',
connectionString: 'mysql://root:pass@127.0.0.1:3306/metapi',
})).toThrow(/postgres/i);
});
it('masks connection string credentials', () => {
const masked = maskConnectionString('postgres://admin:super-secret@db.example.com:5432/metapi');
expect(masked).toBe('postgres://admin:***@db.example.com:5432/metapi');
});
});
@@ -0,0 +1,710 @@
import Database from 'better-sqlite3';
import mysql from 'mysql2/promise';
import pg from 'pg';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { db, schema } from '../db/index.js';
export type MigrationDialect = 'sqlite' | 'mysql' | 'postgres';
export interface DatabaseMigrationInput {
dialect?: unknown;
connectionString?: unknown;
overwrite?: unknown;
}
export interface NormalizedDatabaseMigrationInput {
dialect: MigrationDialect;
connectionString: string;
overwrite: boolean;
}
type BackupSnapshot = {
version: string;
timestamp: number;
accounts: {
sites: Array<Record<string, unknown>>;
accounts: Array<Record<string, unknown>>;
accountTokens: Array<Record<string, unknown>>;
checkinLogs: Array<Record<string, unknown>>;
modelAvailability: Array<Record<string, unknown>>;
tokenModelAvailability: Array<Record<string, unknown>>;
tokenRoutes: Array<Record<string, unknown>>;
routeChannels: Array<Record<string, unknown>>;
proxyLogs: Array<Record<string, unknown>>;
downstreamApiKeys: Array<Record<string, unknown>>;
events: Array<Record<string, unknown>>;
};
preferences: {
settings: Array<{ key: string; value: unknown }>;
};
};
export interface DatabaseMigrationSummary {
dialect: MigrationDialect;
connection: string;
overwrite: boolean;
version: string;
timestamp: number;
rows: {
sites: number;
accounts: number;
accountTokens: number;
tokenRoutes: number;
routeChannels: number;
checkinLogs: number;
modelAvailability: number;
tokenModelAvailability: number;
proxyLogs: number;
downstreamApiKeys: number;
events: number;
settings: number;
};
}
interface SqlClient {
dialect: MigrationDialect;
begin(): Promise<void>;
commit(): Promise<void>;
rollback(): Promise<void>;
execute(sqlText: string, params?: unknown[]): Promise<unknown>;
queryScalar(sqlText: string, params?: unknown[]): Promise<number>;
close(): Promise<void>;
}
interface InsertStatement {
table: string;
columns: string[];
values: unknown[];
}
const DIALECTS: MigrationDialect[] = ['sqlite', 'mysql', 'postgres'];
function asString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function asBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
}
return fallback;
}
function asNumber(value: unknown, fallback: number | null = null): number | null {
if (value === null || value === undefined) return fallback;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function asNullableString(value: unknown): string | null {
if (value === null || value === undefined) return null;
return String(value);
}
function toJsonString(value: unknown): string {
return JSON.stringify(value ?? null);
}
function assertDialectUrl(dialect: MigrationDialect, connectionString: string): void {
if (dialect === 'sqlite') return;
let parsed: URL;
try {
parsed = new URL(connectionString);
} catch {
throw new Error(`数据库连接串无效:${dialect} 需要合法 URL`);
}
if (dialect === 'postgres' && parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') {
throw new Error('PostgreSQL 连接串必须以 postgres:// 或 postgresql:// 开头');
}
if (dialect === 'mysql' && parsed.protocol !== 'mysql:') {
throw new Error('MySQL 连接串必须以 mysql:// 开头');
}
}
function normalizeSqliteTarget(raw: string): string {
if (!raw) throw new Error('SQLite 目标路径不能为空');
if (raw.startsWith('file://')) {
const parsed = new URL(raw);
return decodeURIComponent(parsed.pathname);
}
if (raw.startsWith('sqlite://')) {
return raw.slice('sqlite://'.length).trim();
}
return raw;
}
export function normalizeMigrationInput(input: DatabaseMigrationInput): NormalizedDatabaseMigrationInput {
const rawDialect = asString(input.dialect).toLowerCase();
if (!DIALECTS.includes(rawDialect as MigrationDialect)) {
throw new Error('数据库方言无效,仅支持 sqlite/mysql/postgres');
}
const dialect = rawDialect as MigrationDialect;
let connectionString = asString(input.connectionString);
if (!connectionString) {
throw new Error('数据库连接串不能为空');
}
if (dialect === 'sqlite') {
connectionString = normalizeSqliteTarget(connectionString);
} else {
assertDialectUrl(dialect, connectionString);
}
return {
dialect,
connectionString,
overwrite: input.overwrite === undefined ? true : asBoolean(input.overwrite, true),
};
}
export function maskConnectionString(connectionString: string): string {
try {
const parsed = new URL(connectionString);
if (!parsed.password) return connectionString;
parsed.password = '***';
return parsed.toString();
} catch {
return connectionString;
}
}
function quoteIdent(dialect: MigrationDialect, identifier: string): string {
return dialect === 'mysql' ? `\`${identifier}\`` : `"${identifier}"`;
}
function parseSettingValue(raw: string | null): unknown {
if (raw === null || raw === undefined) return null;
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
async function toBackupSnapshot(): Promise<BackupSnapshot> {
const settingsRows = await db.select().from(schema.settings).all();
return {
version: 'live-db-snapshot',
timestamp: Date.now(),
accounts: {
sites: await db.select().from(schema.sites).all() as Array<Record<string, unknown>>,
accounts: await db.select().from(schema.accounts).all() as Array<Record<string, unknown>>,
accountTokens: await db.select().from(schema.accountTokens).all() as Array<Record<string, unknown>>,
checkinLogs: await db.select().from(schema.checkinLogs).all() as Array<Record<string, unknown>>,
modelAvailability: await db.select().from(schema.modelAvailability).all() as Array<Record<string, unknown>>,
tokenModelAvailability: await db.select().from(schema.tokenModelAvailability).all() as Array<Record<string, unknown>>,
tokenRoutes: await db.select().from(schema.tokenRoutes).all() as Array<Record<string, unknown>>,
routeChannels: await db.select().from(schema.routeChannels).all() as Array<Record<string, unknown>>,
proxyLogs: await db.select().from(schema.proxyLogs).all() as Array<Record<string, unknown>>,
downstreamApiKeys: await db.select().from(schema.downstreamApiKeys).all() as Array<Record<string, unknown>>,
events: await db.select().from(schema.events).all() as Array<Record<string, unknown>>,
},
preferences: {
settings: settingsRows.map((row) => ({ key: row.key, value: parseSettingValue(row.value) })),
},
};
}
async function createPostgresClient(connectionString: string): Promise<SqlClient> {
const client = new pg.Client({ connectionString });
await client.connect();
return {
dialect: 'postgres',
begin: async () => { await client.query('BEGIN'); },
commit: async () => { await client.query('COMMIT'); },
rollback: async () => { await client.query('ROLLBACK'); },
execute: async (sqlText, params = []) => client.query(sqlText, params),
queryScalar: async (sqlText, params = []) => {
const result = await client.query(sqlText, params);
const row = result.rows[0] as Record<string, unknown> | undefined;
if (!row) return 0;
return Number(Object.values(row)[0]) || 0;
},
close: async () => { await client.end(); },
};
}
async function createMySqlClient(connectionString: string): Promise<SqlClient> {
const connection = await mysql.createConnection(connectionString);
return {
dialect: 'mysql',
begin: async () => { await connection.beginTransaction(); },
commit: async () => { await connection.commit(); },
rollback: async () => { await connection.rollback(); },
execute: async (sqlText, params = []) => connection.execute(sqlText, params as any[]),
queryScalar: async (sqlText, params = []) => {
const [rows] = await connection.query(sqlText, params as any[]);
if (!Array.isArray(rows) || rows.length === 0) return 0;
const row = rows[0] as Record<string, unknown>;
return Number(Object.values(row)[0]) || 0;
},
close: async () => { await connection.end(); },
};
}
async function createSqliteClient(connectionString: string): Promise<SqlClient> {
const filePath = resolve(connectionString);
mkdirSync(dirname(filePath), { recursive: true });
const sqlite = new Database(filePath);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
return {
dialect: 'sqlite',
begin: async () => { sqlite.exec('BEGIN'); },
commit: async () => { sqlite.exec('COMMIT'); },
rollback: async () => { sqlite.exec('ROLLBACK'); },
execute: async (sqlText, params = []) => {
const lowered = sqlText.trim().toLowerCase();
const stmt = sqlite.prepare(sqlText);
if (lowered.startsWith('select')) return await stmt.all(...params);
return await stmt.run(...params);
},
queryScalar: async (sqlText, params = []) => {
const row = await sqlite.prepare(sqlText).get(...params) as Record<string, unknown> | undefined;
if (!row) return 0;
return Number(Object.values(row)[0]) || 0;
},
close: async () => { sqlite.close(); },
};
}
async function createClient(input: NormalizedDatabaseMigrationInput): Promise<SqlClient> {
if (input.dialect === 'postgres') return createPostgresClient(input.connectionString);
if (input.dialect === 'mysql') return createMySqlClient(input.connectionString);
return createSqliteClient(input.connectionString);
}
async function ensureSchema(client: SqlClient): Promise<void> {
const statements = client.dialect === 'postgres'
? [
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "global_weight" DOUBLE PRECISION DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" BOOLEAN DEFAULT TRUE, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT TRUE, "is_default" BOOLEAN DEFAULT FALSE, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" BOOLEAN DEFAULT FALSE, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
]
: client.dialect === 'mysql'
? [
`CREATE TABLE IF NOT EXISTS \`sites\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`url\` TEXT NOT NULL, \`external_checkin_url\` TEXT NULL, \`platform\` VARCHAR(64) NOT NULL, \`proxy_url\` TEXT NULL, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`global_weight\` DOUBLE DEFAULT 1, \`api_key\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`accounts\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`site_id\` INT NOT NULL, \`username\` TEXT NULL, \`access_token\` TEXT NOT NULL, \`api_token\` TEXT NULL, \`balance\` DOUBLE DEFAULT 0, \`balance_used\` DOUBLE DEFAULT 0, \`quota\` DOUBLE DEFAULT 0, \`unit_cost\` DOUBLE NULL, \`value_score\` DOUBLE DEFAULT 0, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`checkin_enabled\` BOOLEAN DEFAULT TRUE, \`last_checkin_at\` TEXT NULL, \`last_balance_refresh\` TEXT NULL, \`extra_config\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`accounts_site_fk\` FOREIGN KEY (\`site_id\`) REFERENCES \`sites\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`account_tokens\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`name\` TEXT NOT NULL, \`token\` TEXT NOT NULL, \`token_group\` TEXT NULL, \`source\` VARCHAR(32) DEFAULT 'manual', \`enabled\` BOOLEAN DEFAULT TRUE, \`is_default\` BOOLEAN DEFAULT FALSE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`account_tokens_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`downstream_api_keys\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`key\` VARCHAR(191) NOT NULL UNIQUE, \`description\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`expires_at\` TEXT NULL, \`max_cost\` DOUBLE NULL, \`used_cost\` DOUBLE DEFAULT 0, \`max_requests\` INT NULL, \`used_requests\` INT DEFAULT 0, \`supported_models\` TEXT NULL, \`allowed_route_ids\` TEXT NULL, \`site_weight_multipliers\` TEXT NULL, \`last_used_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`events\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`type\` VARCHAR(32) NOT NULL, \`title\` TEXT NOT NULL, \`message\` TEXT NULL, \`level\` VARCHAR(16) DEFAULT 'info', \`read\` BOOLEAN DEFAULT FALSE, \`related_id\` INT NULL, \`related_type\` VARCHAR(32) NULL, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`,
]
: [
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "global_weight" REAL DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" REAL DEFAULT 0, "balance_used" REAL DEFAULT 0, "quota" REAL DEFAULT 0, "unit_cost" REAL, "value_score" REAL DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" INTEGER DEFAULT 1, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" INTEGER DEFAULT 1, "is_default" INTEGER DEFAULT 0, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" INTEGER DEFAULT 1, "expires_at" TEXT, "max_cost" REAL, "used_cost" REAL DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" INTEGER DEFAULT 0, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
];
for (const sqlText of statements) {
await client.execute(sqlText);
}
}
async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise<void> {
const siteCount = await client.queryScalar(`SELECT COUNT(*) FROM ${quoteIdent(client.dialect, 'sites')}`);
const settingCount = await client.queryScalar(`SELECT COUNT(*) FROM ${quoteIdent(client.dialect, 'settings')}`);
if (!overwrite && (siteCount > 0 || settingCount > 0)) {
throw new Error('目标数据库已包含数据。若确认覆盖,请勾选“覆盖目标数据库现有数据”');
}
}
async function clearTargetData(client: SqlClient): Promise<void> {
const tables = [
'route_channels',
'token_model_availability',
'model_availability',
'checkin_logs',
'proxy_logs',
'account_tokens',
'accounts',
'token_routes',
'sites',
'downstream_api_keys',
'events',
'settings',
];
for (const table of tables) {
await client.execute(`DELETE FROM ${quoteIdent(client.dialect, table)}`);
}
}
function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
const statements: InsertStatement[] = [];
for (const row of snapshot.accounts.sites) {
statements.push({
table: 'sites',
columns: ['id', 'name', 'url', 'external_checkin_url', 'platform', 'proxy_url', 'status', 'is_pinned', 'sort_order', 'global_weight', 'api_key', 'created_at', 'updated_at'],
values: [
asNumber(row.id, 0),
asNullableString(row.name),
asNullableString(row.url),
asNullableString(row.externalCheckinUrl),
asNullableString(row.platform),
asNullableString(row.proxyUrl),
asNullableString(row.status) ?? 'active',
asBoolean(row.isPinned, false),
asNumber(row.sortOrder, 0),
asNumber(row.globalWeight, 1),
asNullableString(row.apiKey),
asNullableString(row.createdAt),
asNullableString(row.updatedAt),
],
});
}
for (const row of snapshot.accounts.accounts) {
statements.push({
table: 'accounts',
columns: ['id', 'site_id', 'username', 'access_token', 'api_token', 'balance', 'balance_used', 'quota', 'unit_cost', 'value_score', 'status', 'is_pinned', 'sort_order', 'checkin_enabled', 'last_checkin_at', 'last_balance_refresh', 'extra_config', 'created_at', 'updated_at'],
values: [
asNumber(row.id, 0),
asNumber(row.siteId, 0),
asNullableString(row.username),
asNullableString(row.accessToken),
asNullableString(row.apiToken),
asNumber(row.balance, 0),
asNumber(row.balanceUsed, 0),
asNumber(row.quota, 0),
asNumber(row.unitCost, null),
asNumber(row.valueScore, 0),
asNullableString(row.status) ?? 'active',
asBoolean(row.isPinned, false),
asNumber(row.sortOrder, 0),
asBoolean(row.checkinEnabled, true),
asNullableString(row.lastCheckinAt),
asNullableString(row.lastBalanceRefresh),
asNullableString(row.extraConfig),
asNullableString(row.createdAt),
asNullableString(row.updatedAt),
],
});
}
for (const row of snapshot.accounts.accountTokens) {
statements.push({
table: 'account_tokens',
columns: ['id', 'account_id', 'name', 'token', 'token_group', 'source', 'enabled', 'is_default', 'created_at', 'updated_at'],
values: [
asNumber(row.id, 0),
asNumber(row.accountId, 0),
asNullableString(row.name),
asNullableString(row.token),
asNullableString(row.tokenGroup),
asNullableString(row.source) ?? 'manual',
asBoolean(row.enabled, true),
asBoolean(row.isDefault, false),
asNullableString(row.createdAt),
asNullableString(row.updatedAt),
],
});
}
for (const row of snapshot.accounts.checkinLogs) {
statements.push({
table: 'checkin_logs',
columns: ['id', 'account_id', 'status', 'message', 'reward', 'created_at'],
values: [
asNumber(row.id, 0),
asNumber(row.accountId, 0),
asNullableString(row.status) ?? 'success',
asNullableString(row.message),
asNullableString(row.reward),
asNullableString(row.createdAt),
],
});
}
for (const row of snapshot.accounts.modelAvailability) {
statements.push({
table: 'model_availability',
columns: ['id', 'account_id', 'model_name', 'available', 'latency_ms', 'checked_at'],
values: [
asNumber(row.id, 0),
asNumber(row.accountId, 0),
asNullableString(row.modelName),
asBoolean(row.available, false),
asNumber(row.latencyMs, null),
asNullableString(row.checkedAt),
],
});
}
for (const row of snapshot.accounts.tokenModelAvailability) {
statements.push({
table: 'token_model_availability',
columns: ['id', 'token_id', 'model_name', 'available', 'latency_ms', 'checked_at'],
values: [
asNumber(row.id, 0),
asNumber(row.tokenId, 0),
asNullableString(row.modelName),
asBoolean(row.available, false),
asNumber(row.latencyMs, null),
asNullableString(row.checkedAt),
],
});
}
for (const row of snapshot.accounts.tokenRoutes) {
statements.push({
table: 'token_routes',
columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'enabled', 'created_at', 'updated_at'],
values: [
asNumber(row.id, 0),
asNullableString(row.modelPattern),
asNullableString(row.displayName),
asNullableString(row.displayIcon),
asNullableString(row.modelMapping),
asBoolean(row.enabled, true),
asNullableString(row.createdAt),
asNullableString(row.updatedAt),
],
});
}
for (const row of snapshot.accounts.routeChannels) {
statements.push({
table: 'route_channels',
columns: ['id', 'route_id', 'account_id', 'token_id', 'source_model', 'priority', 'weight', 'enabled', 'manual_override', 'success_count', 'fail_count', 'total_latency_ms', 'total_cost', 'last_used_at', 'last_fail_at', 'cooldown_until'],
values: [
asNumber(row.id, 0),
asNumber(row.routeId, 0),
asNumber(row.accountId, 0),
asNumber(row.tokenId, null),
asNullableString(row.sourceModel),
asNumber(row.priority, 0),
asNumber(row.weight, 10),
asBoolean(row.enabled, true),
asBoolean(row.manualOverride, false),
asNumber(row.successCount, 0),
asNumber(row.failCount, 0),
asNumber(row.totalLatencyMs, 0),
asNumber(row.totalCost, 0),
asNullableString(row.lastUsedAt),
asNullableString(row.lastFailAt),
asNullableString(row.cooldownUntil),
],
});
}
for (const row of snapshot.accounts.proxyLogs) {
statements.push({
table: 'proxy_logs',
columns: ['id', 'route_id', 'channel_id', 'account_id', 'model_requested', 'model_actual', 'status', 'http_status', 'latency_ms', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'estimated_cost', 'error_message', 'retry_count', 'created_at'],
values: [
asNumber(row.id, 0),
asNumber(row.routeId, null),
asNumber(row.channelId, null),
asNumber(row.accountId, null),
asNullableString(row.modelRequested),
asNullableString(row.modelActual),
asNullableString(row.status),
asNumber(row.httpStatus, null),
asNumber(row.latencyMs, null),
asNumber(row.promptTokens, null),
asNumber(row.completionTokens, null),
asNumber(row.totalTokens, null),
asNumber(row.estimatedCost, null),
asNullableString(row.errorMessage),
asNumber(row.retryCount, 0),
asNullableString(row.createdAt),
],
});
}
for (const row of snapshot.accounts.downstreamApiKeys) {
statements.push({
table: 'downstream_api_keys',
columns: ['id', 'name', 'key', 'description', 'enabled', 'expires_at', 'max_cost', 'used_cost', 'max_requests', 'used_requests', 'supported_models', 'allowed_route_ids', 'site_weight_multipliers', 'last_used_at', 'created_at', 'updated_at'],
values: [
asNumber(row.id, 0),
asNullableString(row.name),
asNullableString(row.key),
asNullableString(row.description),
asBoolean(row.enabled, true),
asNullableString(row.expiresAt),
asNumber(row.maxCost, null),
asNumber(row.usedCost, 0),
asNumber(row.maxRequests, null),
asNumber(row.usedRequests, 0),
asNullableString(row.supportedModels),
asNullableString(row.allowedRouteIds),
asNullableString(row.siteWeightMultipliers),
asNullableString(row.lastUsedAt),
asNullableString(row.createdAt),
asNullableString(row.updatedAt),
],
});
}
for (const row of snapshot.accounts.events) {
statements.push({
table: 'events',
columns: ['id', 'type', 'title', 'message', 'level', 'read', 'related_id', 'related_type', 'created_at'],
values: [
asNumber(row.id, 0),
asNullableString(row.type),
asNullableString(row.title),
asNullableString(row.message),
asNullableString(row.level) ?? 'info',
asBoolean(row.read, false),
asNumber(row.relatedId, null),
asNullableString(row.relatedType),
asNullableString(row.createdAt),
],
});
}
for (const row of snapshot.preferences.settings) {
statements.push({
table: 'settings',
columns: ['key', 'value'],
values: [row.key, toJsonString(row.value)],
});
}
return statements;
}
function buildInsertSql(dialect: MigrationDialect, statement: InsertStatement): { sqlText: string; params: unknown[] } {
const table = quoteIdent(dialect, statement.table);
const columns = statement.columns.map((item) => quoteIdent(dialect, item)).join(', ');
const placeholders = statement.columns.map((_, index) => (dialect === 'postgres' ? `$${index + 1}` : '?')).join(', ');
const params = statement.values.map((value) => {
if (dialect === 'sqlite' && typeof value === 'boolean') {
return value ? 1 : 0;
}
return value;
});
return {
sqlText: `INSERT INTO ${table} (${columns}) VALUES (${placeholders})`,
params,
};
}
async function insertAllRows(client: SqlClient, statements: InsertStatement[]): Promise<void> {
for (const statement of statements) {
const { sqlText, params } = buildInsertSql(client.dialect, statement);
await client.execute(sqlText, params);
}
}
async function syncPostgresSequences(client: SqlClient): Promise<void> {
if (client.dialect !== 'postgres') return;
const tables = [
'sites',
'accounts',
'account_tokens',
'checkin_logs',
'model_availability',
'token_model_availability',
'token_routes',
'route_channels',
'proxy_logs',
'downstream_api_keys',
'events',
];
for (const table of tables) {
await client.execute(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM "${table}"), 1), TRUE)`);
}
}
export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Promise<DatabaseMigrationSummary> {
const normalized = normalizeMigrationInput(input);
const snapshot = await toBackupSnapshot();
const client = await createClient(normalized);
try {
await ensureSchema(client);
await ensureTargetState(client, normalized.overwrite);
await client.begin();
try {
if (normalized.overwrite) {
await clearTargetData(client);
}
await insertAllRows(client, buildStatements(snapshot));
await syncPostgresSequences(client);
await client.commit();
} catch (error) {
await client.rollback();
throw error;
}
} finally {
await client.close();
}
return {
dialect: normalized.dialect,
connection: maskConnectionString(normalized.connectionString),
overwrite: normalized.overwrite,
version: snapshot.version,
timestamp: snapshot.timestamp,
rows: {
sites: snapshot.accounts.sites.length,
accounts: snapshot.accounts.accounts.length,
accountTokens: snapshot.accounts.accountTokens.length,
tokenRoutes: snapshot.accounts.tokenRoutes.length,
routeChannels: snapshot.accounts.routeChannels.length,
checkinLogs: snapshot.accounts.checkinLogs.length,
modelAvailability: snapshot.accounts.modelAvailability.length,
tokenModelAvailability: snapshot.accounts.tokenModelAvailability.length,
proxyLogs: snapshot.accounts.proxyLogs.length,
downstreamApiKeys: snapshot.accounts.downstreamApiKeys.length,
events: snapshot.accounts.events.length,
settings: snapshot.preferences.settings.length,
},
};
}
export async function testDatabaseConnection(input: DatabaseMigrationInput): Promise<{ dialect: MigrationDialect; connection: string }> {
const normalized = normalizeMigrationInput(input);
const client = await createClient(normalized);
try {
await client.execute('SELECT 1');
} finally {
await client.close();
}
return {
dialect: normalized.dialect,
connection: maskConnectionString(normalized.connectionString),
};
}
@@ -29,9 +29,9 @@ describe('downstreamApiKeyService', () => {
service = serviceModule;
});
beforeEach(() => {
db.delete(schema.downstreamApiKeys).run();
db.delete(schema.tokenRoutes).run();
beforeEach(async () => {
await db.delete(schema.downstreamApiKeys).run();
await db.delete(schema.tokenRoutes).run();
config.proxyToken = 'sk-global-proxy-token';
});
@@ -39,8 +39,8 @@ describe('downstreamApiKeyService', () => {
delete process.env.DATA_DIR;
});
it('authorizes global proxy token when no managed key matches', () => {
const result = service.authorizeDownstreamToken('sk-global-proxy-token');
it('authorizes global proxy token when no managed key matches', async () => {
const result = await service.authorizeDownstreamToken('sk-global-proxy-token');
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.key).toBeNull();
@@ -49,23 +49,23 @@ describe('downstreamApiKeyService', () => {
}
});
it('rejects managed keys by lifecycle guards (disabled, expired, over budget, over requests)', () => {
it('rejects managed keys by lifecycle guards (disabled, expired, over budget, over requests)', async () => {
const now = Date.now();
const disabled = db.insert(schema.downstreamApiKeys).values({
const disabled = await db.insert(schema.downstreamApiKeys).values({
name: 'disabled',
key: 'sk-disabled',
enabled: false,
}).returning().get();
const expired = db.insert(schema.downstreamApiKeys).values({
const expired = await db.insert(schema.downstreamApiKeys).values({
name: 'expired',
key: 'sk-expired',
enabled: true,
expiresAt: new Date(now - 60_000).toISOString(),
}).returning().get();
const overBudget = db.insert(schema.downstreamApiKeys).values({
const overBudget = await db.insert(schema.downstreamApiKeys).values({
name: 'over-budget',
key: 'sk-over-budget',
enabled: true,
@@ -73,7 +73,7 @@ describe('downstreamApiKeyService', () => {
usedCost: 1.2,
}).returning().get();
const overRequests = db.insert(schema.downstreamApiKeys).values({
const overRequests = await db.insert(schema.downstreamApiKeys).values({
name: 'over-requests',
key: 'sk-over-requests',
enabled: true,
@@ -81,10 +81,10 @@ describe('downstreamApiKeyService', () => {
usedRequests: 10,
}).returning().get();
const r1 = service.authorizeDownstreamToken(disabled.key);
const r2 = service.authorizeDownstreamToken(expired.key);
const r3 = service.authorizeDownstreamToken(overBudget.key);
const r4 = service.authorizeDownstreamToken(overRequests.key);
const r1 = await service.authorizeDownstreamToken(disabled.key);
const r2 = await service.authorizeDownstreamToken(expired.key);
const r3 = await service.authorizeDownstreamToken(overBudget.key);
const r4 = await service.authorizeDownstreamToken(overRequests.key);
expect(r1.ok).toBe(false);
expect(r2.ok).toBe(false);
@@ -92,8 +92,8 @@ describe('downstreamApiKeyService', () => {
expect(r4.ok).toBe(false);
});
it('parses policy fields and supports model matching patterns', () => {
const row = db.insert(schema.downstreamApiKeys).values({
it('parses policy fields and supports model matching patterns', async () => {
const row = await db.insert(schema.downstreamApiKeys).values({
name: 'project-a',
key: 'sk-project-a',
enabled: true,
@@ -102,7 +102,7 @@ describe('downstreamApiKeyService', () => {
siteWeightMultipliers: JSON.stringify({ '1': 2.5, '7': 0.4 }),
}).returning().get();
const result = service.authorizeDownstreamToken(row.key);
const result = await service.authorizeDownstreamToken(row.key);
expect(result.ok).toBe(true);
if (!result.ok) return;
@@ -116,8 +116,8 @@ describe('downstreamApiKeyService', () => {
expect(service.isModelAllowedByPolicy('gemini-2.0-flash', result.policy)).toBe(false);
});
it('treats selected groups as additional allowed model scope (union semantics)', () => {
const claudeGroup = db.insert(schema.tokenRoutes).values({
it('treats selected groups as additional allowed model scope (union semantics)', async () => {
const claudeGroup = await db.insert(schema.tokenRoutes).values({
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
enabled: true,
}).returning().get();
@@ -129,12 +129,12 @@ describe('downstreamApiKeyService', () => {
};
expect(service.isModelAllowedByPolicy('claude-opus-4-6', policy)).toBe(false);
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
expect(service.isModelAllowedByPolicyOrAllowedRoutes('gemini-2.0-flash', policy)).toBe(false);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gemini-2.0-flash', policy)).toBe(false);
});
it('authorizes by selected group model pattern only, not arbitrary internal models', () => {
const virtualModelGroup = db.insert(schema.tokenRoutes).values({
it('authorizes by selected group model pattern only, not arbitrary internal models', async () => {
const virtualModelGroup = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-opus-4-6',
enabled: true,
}).returning().get();
@@ -145,12 +145,12 @@ describe('downstreamApiKeyService', () => {
siteWeightMultipliers: {},
};
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-sonnet-4-6', policy)).toBe(false);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-sonnet-4-6', policy)).toBe(false);
});
it('authorizes models by selected route display name alias', () => {
const aliasRoute = db.insert(schema.tokenRoutes).values({
it('authorizes models by selected route display name alias', async () => {
const aliasRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 're:^claude-(opus|sonnet)-4-5$',
displayName: 'claude-opus-4-6',
enabled: true,
@@ -162,14 +162,14 @@ describe('downstreamApiKeyService', () => {
siteWeightMultipliers: {},
};
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-sonnet-4-5', policy)).toBe(true);
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-5', policy)).toBe(true);
expect(service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-sonnet-4-5', policy)).toBe(true);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-5', policy)).toBe(true);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false);
});
it('accumulates managed key request/cost usage and applies limits', () => {
const row = db.insert(schema.downstreamApiKeys).values({
it('accumulates managed key request/cost usage and applies limits', async () => {
const row = await db.insert(schema.downstreamApiKeys).values({
name: 'metered-key',
key: 'sk-metered-key',
enabled: true,
@@ -179,16 +179,16 @@ describe('downstreamApiKeyService', () => {
usedCost: 0,
}).returning().get();
service.consumeManagedKeyRequest(row.id);
service.consumeManagedKeyRequest(row.id);
service.recordManagedKeyCostUsage(row.id, 0.4);
service.recordManagedKeyCostUsage(row.id, 0.6);
await service.consumeManagedKeyRequest(row.id);
await service.consumeManagedKeyRequest(row.id);
await service.recordManagedKeyCostUsage(row.id, 0.4);
await service.recordManagedKeyCostUsage(row.id, 0.6);
const latest = service.getDownstreamApiKeyById(row.id);
const latest = await service.getDownstreamApiKeyById(row.id);
expect(latest?.usedRequests).toBe(2);
expect(latest?.usedCost).toBeCloseTo(1);
const authResult = service.authorizeDownstreamToken(row.key);
const authResult = await service.authorizeDownstreamToken(row.key);
expect(authResult.ok).toBe(false);
});
});
+17 -17
View File
@@ -176,10 +176,10 @@ export function isModelAllowedByPolicy(model: string, policy: DownstreamRoutingP
return patterns.some((pattern) => matchesDownstreamModelPattern(model, pattern));
}
function isModelMatchedByAllowedRoutes(model: string, allowedRouteIds: number[]): boolean {
async function isModelMatchedByAllowedRoutes(model: string, allowedRouteIds: number[]): Promise<boolean> {
if (allowedRouteIds.length === 0) return false;
const routes = db.select({
const routes = await db.select({
id: schema.tokenRoutes.id,
modelPattern: schema.tokenRoutes.modelPattern,
displayName: schema.tokenRoutes.displayName,
@@ -198,7 +198,7 @@ function isModelMatchedByAllowedRoutes(model: string, allowedRouteIds: number[])
});
}
export function isModelAllowedByPolicyOrAllowedRoutes(model: string, policy: DownstreamRoutingPolicy): boolean {
export async function isModelAllowedByPolicyOrAllowedRoutes(model: string, policy: DownstreamRoutingPolicy): Promise<boolean> {
const patterns = normalizeSupportedModelsInput(policy.supportedModels);
const allowedRouteIds = normalizeAllowedRouteIdsInput(policy.allowedRouteIds);
const hasPatternRules = patterns.length > 0;
@@ -212,7 +212,7 @@ export function isModelAllowedByPolicyOrAllowedRoutes(model: string, policy: Dow
if (!hasRouteRules) return false;
return isModelMatchedByAllowedRoutes(model, allowedRouteIds);
return await isModelMatchedByAllowedRoutes(model, allowedRouteIds);
}
export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): DownstreamApiKeyPolicyView {
@@ -249,26 +249,26 @@ export function toPolicyFromView(view: Pick<DownstreamApiKeyPolicyView, 'support
};
}
export function listDownstreamApiKeys(): DownstreamApiKeyPolicyView[] {
return db.select().from(schema.downstreamApiKeys)
.all()
export async function listDownstreamApiKeys(): Promise<DownstreamApiKeyPolicyView[]> {
return (await db.select().from(schema.downstreamApiKeys)
.all())
.map((row) => toDownstreamApiKeyPolicyView(row))
.sort((a, b) => b.id - a.id);
}
export function getDownstreamApiKeyById(id: number): DownstreamApiKeyPolicyView | null {
const row = db.select().from(schema.downstreamApiKeys)
export async function getDownstreamApiKeyById(id: number): Promise<DownstreamApiKeyPolicyView | null> {
const row = await db.select().from(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.id, id))
.get();
if (!row) return null;
return toDownstreamApiKeyPolicyView(row);
}
export function getManagedDownstreamApiKeyByToken(token: string): DownstreamApiKeyPolicyView | null {
export async function getManagedDownstreamApiKeyByToken(token: string): Promise<DownstreamApiKeyPolicyView | null> {
const normalizedToken = normalizeToken(token);
if (!normalizedToken) return null;
const row = db.select().from(schema.downstreamApiKeys)
const row = await db.select().from(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.key, normalizedToken))
.get();
@@ -280,7 +280,7 @@ export function getDefaultGlobalPolicy(): DownstreamRoutingPolicy {
return EMPTY_DOWNSTREAM_ROUTING_POLICY;
}
export function authorizeDownstreamToken(token: string): DownstreamTokenAuthResult {
export async function authorizeDownstreamToken(token: string): Promise<DownstreamTokenAuthResult> {
const normalizedToken = normalizeToken(token);
if (!normalizedToken) {
return {
@@ -291,7 +291,7 @@ export function authorizeDownstreamToken(token: string): DownstreamTokenAuthResu
};
}
const managed = getManagedDownstreamApiKeyByToken(normalizedToken);
const managed = await getManagedDownstreamApiKeyByToken(normalizedToken);
if (managed) {
if (!managed.enabled) {
return {
@@ -359,9 +359,9 @@ export function authorizeDownstreamToken(token: string): DownstreamTokenAuthResu
};
}
export function consumeManagedKeyRequest(keyId: number): void {
export async function consumeManagedKeyRequest(keyId: number): Promise<void> {
const nowIso = new Date().toISOString();
db.update(schema.downstreamApiKeys).set({
await db.update(schema.downstreamApiKeys).set({
// Atomic increment to avoid lost updates under multi-process concurrency.
usedRequests: sql`coalesce(${schema.downstreamApiKeys.usedRequests}, 0) + 1`,
lastUsedAt: nowIso,
@@ -369,11 +369,11 @@ export function consumeManagedKeyRequest(keyId: number): void {
}).where(eq(schema.downstreamApiKeys.id, keyId)).run();
}
export function recordManagedKeyCostUsage(keyId: number, estimatedCost: number): void {
export async function recordManagedKeyCostUsage(keyId: number, estimatedCost: number): Promise<void> {
const cost = Number(estimatedCost);
if (!Number.isFinite(cost) || cost <= 0) return;
const nowIso = new Date().toISOString();
db.update(schema.downstreamApiKeys).set({
await db.update(schema.downstreamApiKeys).set({
// Atomic increment to avoid lost updates under multi-process concurrency.
usedCost: sql`coalesce(${schema.downstreamApiKeys.usedCost}, 0) + ${cost}`,
lastUsedAt: nowIso,
+2 -2
View File
@@ -259,7 +259,7 @@ async function fetchJson(url: string, options?: UndiciRequestInit): Promise<unkn
try {
const response = await fetch(url, {
...withSiteProxyRequestInit(url, {
...(await withSiteProxyRequestInit(url, {
...options,
signal: controller.signal,
body: options?.body ?? undefined,
@@ -267,7 +267,7 @@ async function fetchJson(url: string, options?: UndiciRequestInit): Promise<unkn
'Content-Type': 'application/json',
...options?.headers,
},
}),
})),
});
if (!response.ok) {
@@ -36,17 +36,17 @@ describe('refreshModelsForAccount credential discovery', () => {
refreshModelsForAccount = modelService.refreshModelsForAccount;
});
beforeEach(() => {
beforeEach(async () => {
getApiTokenMock.mockReset();
getModelsMock.mockReset();
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();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(() => {
@@ -59,14 +59,14 @@ describe('refreshModelsForAccount credential discovery', () => {
token === 'session-token' ? ['claude-sonnet-4-5-20250929', 'claude-opus-4-6'] : []
));
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'site-a',
url: 'https://site-a.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'alice',
accessToken: 'session-token',
@@ -84,7 +84,7 @@ describe('refreshModelsForAccount credential discovery', () => {
discoveredByCredential: true,
});
const rows = db.select().from(schema.modelAvailability)
const rows = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();
expect(rows.map((row) => row.modelName).sort()).toEqual([
@@ -92,7 +92,7 @@ describe('refreshModelsForAccount credential discovery', () => {
'claude-sonnet-4-5-20250929',
]);
const tokenRows = db.select().from(schema.tokenModelAvailability).all();
const tokenRows = await db.select().from(schema.tokenModelAvailability).all();
expect(tokenRows).toHaveLength(0);
});
});
+23 -23
View File
@@ -26,35 +26,35 @@ describe('rebuildTokenRoutesFromAvailability', () => {
rebuildTokenRoutesFromAvailability = modelService.rebuildTokenRoutesFromAvailability;
});
beforeEach(() => {
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();
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(() => {
delete process.env.DATA_DIR;
});
it('removes stale exact routes and keeps wildcard routes on rebuild', () => {
const site = db.insert(schema.sites).values({
it('removes stale exact routes and keeps wildcard routes on rebuild', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-1',
url: 'https://site-1.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'user-1',
accessToken: 'access-1',
status: 'active',
}).returning().get();
const token = db.insert(schema.accountTokens).values({
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'sk-test',
@@ -63,18 +63,18 @@ describe('rebuildTokenRoutesFromAvailability', () => {
isDefault: true,
}).returning().get();
db.insert(schema.tokenModelAvailability).values({
await db.insert(schema.tokenModelAvailability).values({
tokenId: token.id,
modelName: 'latest-model',
available: true,
}).run();
const staleRoute = db.insert(schema.tokenRoutes).values({
const staleRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'old-model',
enabled: true,
}).returning().get();
db.insert(schema.routeChannels).values({
await db.insert(schema.routeChannels).values({
routeId: staleRoute.id,
accountId: account.id,
tokenId: token.id,
@@ -84,12 +84,12 @@ describe('rebuildTokenRoutesFromAvailability', () => {
manualOverride: false,
}).run();
const wildcardRoute = db.insert(schema.tokenRoutes).values({
const wildcardRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-*',
enabled: true,
}).returning().get();
db.insert(schema.routeChannels).values({
await db.insert(schema.routeChannels).values({
routeId: wildcardRoute.id,
accountId: account.id,
tokenId: token.id,
@@ -99,25 +99,25 @@ describe('rebuildTokenRoutesFromAvailability', () => {
manualOverride: false,
}).run();
const rebuild = rebuildTokenRoutesFromAvailability();
const rebuild = await rebuildTokenRoutesFromAvailability();
expect(rebuild.models).toBe(1);
expect(rebuild.removedRoutes).toBe(1);
const oldRoute = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, staleRoute.id)).get();
const oldRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, staleRoute.id)).get();
expect(oldRoute).toBeUndefined();
const oldChannels = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.routeId, staleRoute.id)).all();
const oldChannels = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.routeId, staleRoute.id)).all();
expect(oldChannels).toHaveLength(0);
const latestRoute = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.modelPattern, 'latest-model')).get();
const latestRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.modelPattern, 'latest-model')).get();
expect(latestRoute).toBeDefined();
const latestChannels = db.select().from(schema.routeChannels)
const latestChannels = await db.select().from(schema.routeChannels)
.where(and(eq(schema.routeChannels.routeId, latestRoute!.id), eq(schema.routeChannels.tokenId, token.id)))
.all();
expect(latestChannels.length).toBeGreaterThan(0);
const wildcardRouteAfter = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, wildcardRoute.id)).get();
const wildcardRouteAfter = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, wildcardRoute.id)).get();
expect(wildcardRouteAfter).toBeDefined();
});
});
+53 -44
View File
@@ -39,7 +39,7 @@ async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMe
}
export async function refreshModelsForAccount(accountId: number) {
const row = db.select().from(schema.accounts)
const row = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
.get();
@@ -52,17 +52,17 @@ export async function refreshModelsForAccount(accountId: number) {
const site = row.sites;
const adapter = getAdapter(site.platform);
const accountTokens = db.select()
const accountTokens = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, accountId))
.all();
db.delete(schema.modelAvailability)
await db.delete(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, accountId))
.run();
for (const token of accountTokens) {
db.delete(schema.tokenModelAvailability)
await db.delete(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, token.id))
.run();
}
@@ -87,7 +87,7 @@ export async function refreshModelsForAccount(accountId: number) {
);
if (discoveredApiToken) {
ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' });
db.update(schema.accounts).set({
await db.update(schema.accounts).set({
apiToken: discoveredApiToken,
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, account.id)).run();
@@ -95,7 +95,7 @@ export async function refreshModelsForAccount(accountId: number) {
} catch {}
}
let enabledTokens = db.select()
let enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.all();
@@ -105,7 +105,7 @@ export async function refreshModelsForAccount(accountId: number) {
const fallback = discoveredApiToken || account.apiToken || null;
if (fallback) {
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
enabledTokens = db.select()
enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.all();
@@ -182,7 +182,7 @@ export async function refreshModelsForAccount(accountId: number) {
const latencyMs = Date.now() - startedAt;
const checkedAt = new Date().toISOString();
db.insert(schema.tokenModelAvailability).values(
await db.insert(schema.tokenModelAvailability).values(
models.map((modelName) => ({
tokenId: token.id,
modelName,
@@ -198,7 +198,7 @@ export async function refreshModelsForAccount(accountId: number) {
if (accountModels.size > 0) {
const checkedAt = new Date().toISOString();
db.insert(schema.modelAvailability).values(
await db.insert(schema.modelAvailability).values(
Array.from(accountModels).map((modelName) => ({
accountId: account.id,
modelName,
@@ -220,11 +220,11 @@ export async function refreshModelsForAccount(accountId: number) {
}
async function refreshModelsForAllActiveAccounts() {
const accounts = db.select({ id: schema.accounts.id }).from(schema.accounts)
const accounts = await db.select({ id: schema.accounts.id }).from(schema.accounts)
.where(eq(schema.accounts.status, 'active'))
.all();
const results = [];
const results: any[] = [];
for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) {
const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE);
const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id)));
@@ -233,8 +233,8 @@ async function refreshModelsForAllActiveAccounts() {
return results;
}
export function rebuildTokenRoutesFromAvailability() {
const tokenRows = db.select().from(schema.tokenModelAvailability)
export async function rebuildTokenRoutesFromAvailability() {
const tokenRows = await 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))
@@ -255,8 +255,8 @@ export function rebuildTokenRoutesFromAvailability() {
modelTokens.get(modelName)!.set(row.account_tokens.id, row.accounts.id);
}
const routes = db.select().from(schema.tokenRoutes).all();
const channels = db.select().from(schema.routeChannels).all();
const routes = await db.select().from(schema.tokenRoutes).all();
const channels = await db.select().from(schema.routeChannels).all();
let createdRoutes = 0;
let createdChannels = 0;
@@ -264,15 +264,20 @@ export function rebuildTokenRoutesFromAvailability() {
let removedRoutes = 0;
for (const [modelName, tokenAccountMap] of modelTokens.entries()) {
let route = routes.find((r) => r.modelPattern === modelName);
if (!route) {
route = db.insert(schema.tokenRoutes).values({
modelPattern: modelName,
enabled: true,
}).returning().get();
routes.push(route);
createdRoutes++;
}
let route = routes.find((r) => r.modelPattern === modelName);
if (!route) {
const inserted = await db.insert(schema.tokenRoutes).values({
modelPattern: modelName,
enabled: true,
}).run();
const insertedId = Number(inserted.lastInsertRowid || 0);
route = insertedId > 0
? await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, insertedId)).get()
: undefined;
if (!route) continue;
routes.push(route);
createdRoutes++;
}
const routeChannels = channels.filter((channel) => channel.routeId === route.id);
const desiredTokenIds = new Set<number>(Array.from(tokenAccountMap.keys()));
@@ -281,17 +286,21 @@ export function rebuildTokenRoutesFromAvailability() {
const exists = routeChannels.some((channel) => channel.tokenId === tokenId);
if (exists) continue;
const created = db.insert(schema.routeChannels).values({
routeId: route.id,
accountId,
tokenId,
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
}).returning().get();
channels.push(created);
createdChannels++;
const inserted = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId,
tokenId,
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
}).run();
const insertedId = Number(inserted.lastInsertRowid || 0);
if (insertedId <= 0) continue;
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, insertedId)).get();
if (!created) continue;
channels.push(created);
createdChannels++;
}
for (const channel of routeChannels) {
@@ -300,9 +309,9 @@ export function rebuildTokenRoutesFromAvailability() {
}
if (!channel.tokenId) {
const preferred = getPreferredAccountToken(channel.accountId);
const preferred = await getPreferredAccountToken(channel.accountId);
if (preferred && desiredTokenIds.has(preferred.id)) {
db.update(schema.routeChannels)
await db.update(schema.routeChannels)
.set({ tokenId: preferred.id })
.where(eq(schema.routeChannels.id, channel.id))
.run();
@@ -311,7 +320,7 @@ export function rebuildTokenRoutesFromAvailability() {
}
if (!channel.manualOverride) {
db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run();
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run();
removedChannels++;
}
}
@@ -329,7 +338,7 @@ export function rebuildTokenRoutesFromAvailability() {
removedChannels += routeChannelCount;
}
const deleted = db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run().changes;
const deleted = (await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run()).changes;
if (deleted > 0) {
removedRoutes += deleted;
}
@@ -346,8 +355,8 @@ export function rebuildTokenRoutesFromAvailability() {
};
}
export async function refreshModelsAndRebuildRoutes() {
const refresh = await refreshModelsForAllActiveAccounts();
const rebuild = rebuildTokenRoutesFromAvailability();
return { refresh, rebuild };
}
export async function refreshModelsAndRebuildRoutes() {
const refresh = await refreshModelsForAllActiveAccounts();
const rebuild = await rebuildTokenRoutesFromAvailability();
return { refresh, rebuild };
}
+1 -1
View File
@@ -185,7 +185,7 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
...options?.headers,
},
};
const proxiedRequestOptions = withSiteProxyRequestInit(url, requestOptions);
const proxiedRequestOptions = await withSiteProxyRequestInit(url, requestOptions);
const res = await fetch(url, proxiedRequestOptions);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
+1 -1
View File
@@ -516,7 +516,7 @@ export class NewApiAdapter extends BasePlatformAdapter {
body: options?.body ?? undefined,
headers,
};
const proxiedRequestOptions = withSiteProxyRequestInit(url, requestOptions);
const proxiedRequestOptions = await withSiteProxyRequestInit(url, requestOptions);
const res = await fetch(url, proxiedRequestOptions);
const text = await res.text();
const getSetCookie = (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie;
@@ -32,10 +32,10 @@ describe('proxyLogRetentionService', () => {
originalRetentionDays = config.proxyLogRetentionDays;
});
beforeEach(() => {
db.delete(schema.proxyLogs).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
beforeEach(async () => {
await db.delete(schema.proxyLogs).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(() => {
@@ -43,15 +43,15 @@ describe('proxyLogRetentionService', () => {
delete process.env.DATA_DIR;
});
function seedAccount(platform = 'new-api') {
const site = db.insert(schema.sites).values({
async function seedAccount(platform = 'new-api') {
const site = await db.insert(schema.sites).values({
name: `retention-site-${platform}`,
url: `https://retention-${platform}.example.com`,
platform,
status: 'active',
}).returning().get();
return db.insert(schema.accounts).values({
return await db.insert(schema.accounts).values({
siteId: site.id,
username: `retention-${platform}`,
accessToken: `access-${platform}`,
@@ -60,11 +60,11 @@ describe('proxyLogRetentionService', () => {
}).returning().get();
}
it('deletes proxy logs older than retention days', () => {
it('deletes proxy logs older than retention days', async () => {
config.proxyLogRetentionDays = 7;
const account = seedAccount('new-api');
const account = await seedAccount('new-api');
db.insert(schema.proxyLogs).values([
await db.insert(schema.proxyLogs).values([
{
accountId: account.id,
modelRequested: 'gpt-4o-mini',
@@ -79,19 +79,19 @@ describe('proxyLogRetentionService', () => {
},
]).run();
const result = cleanupExpiredProxyLogs(Date.parse('2026-03-04T00:00:00Z'));
const result = await cleanupExpiredProxyLogs(Date.parse('2026-03-04T00:00:00Z'));
expect(result.deleted).toBe(1);
const rows = db.select().from(schema.proxyLogs).all();
const rows = await db.select().from(schema.proxyLogs).all();
expect(rows).toHaveLength(1);
expect(rows[0]?.createdAt).toBe('2026-03-01 12:00:00');
});
it('skips cleanup when retention is disabled', () => {
it('skips cleanup when retention is disabled', async () => {
config.proxyLogRetentionDays = 0;
const account = seedAccount('new-api');
const account = await seedAccount('new-api');
db.insert(schema.proxyLogs).values({
await db.insert(schema.proxyLogs).values({
accountId: account.id,
modelRequested: 'gpt-4o-mini',
status: 'success',
@@ -101,10 +101,10 @@ describe('proxyLogRetentionService', () => {
const cutoff = getProxyLogRetentionCutoffUtc(Date.parse('2026-03-04T00:00:00Z'));
expect(cutoff).toBeNull();
const result = cleanupExpiredProxyLogs(Date.parse('2026-03-04T00:00:00Z'));
const result = await cleanupExpiredProxyLogs(Date.parse('2026-03-04T00:00:00Z'));
expect(result.deleted).toBe(0);
const rows = db.select().from(schema.proxyLogs).all();
const rows = await db.select().from(schema.proxyLogs).all();
expect(rows).toHaveLength(1);
});
});
@@ -13,12 +13,12 @@ export function getProxyLogRetentionCutoffUtc(nowMs = Date.now()): string | null
return formatUtcSqlDateTime(new Date(nowMs - days * DAY_MS));
}
export function cleanupExpiredProxyLogs(nowMs = Date.now()): {
export async function cleanupExpiredProxyLogs(nowMs = Date.now()): Promise<{
enabled: boolean;
retentionDays: number;
cutoffUtc: string | null;
deleted: number;
} {
}> {
const retentionDays = Math.max(0, Math.trunc(config.proxyLogRetentionDays));
const cutoffUtc = getProxyLogRetentionCutoffUtc(nowMs);
if (!cutoffUtc) {
@@ -30,9 +30,9 @@ export function cleanupExpiredProxyLogs(nowMs = Date.now()): {
};
}
const deleted = db.delete(schema.proxyLogs)
const deleted = (await db.delete(schema.proxyLogs)
.where(lt(schema.proxyLogs.createdAt, cutoffUtc))
.run()
.run())
.changes;
return {
@@ -48,9 +48,9 @@ export function startProxyLogRetentionService(): void {
const intervalMinutes = Math.max(1, Math.trunc(config.proxyLogRetentionPruneIntervalMinutes));
const intervalMs = intervalMinutes * 60 * 1000;
const runCleanup = () => {
const runCleanup = async () => {
try {
const result = cleanupExpiredProxyLogs();
const result = await cleanupExpiredProxyLogs();
if (!result.enabled || result.deleted <= 0) return;
console.info(`[proxy-log-retention] deleted ${result.deleted} logs before ${result.cutoffUtc}`);
} catch (error) {
@@ -58,8 +58,8 @@ export function startProxyLogRetentionService(): void {
}
};
runCleanup();
retentionTimer = setInterval(runCleanup, intervalMs);
void runCleanup();
retentionTimer = setInterval(() => { void runCleanup(); }, intervalMs);
retentionTimer.unref?.();
}
+7 -7
View File
@@ -21,8 +21,8 @@ describe('siteProxy', () => {
beforeEach(async () => {
const { invalidateSiteProxyCache } = await import('./siteProxy.js');
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
invalidateSiteProxyCache();
});
@@ -31,7 +31,7 @@ describe('siteProxy', () => {
});
it('resolves longest matched site proxy url', async () => {
db.insert(schema.sites).values([
await db.insert(schema.sites).values([
{
name: 'base-site',
url: 'https://relay.example.com',
@@ -47,14 +47,14 @@ describe('siteProxy', () => {
]).run();
const { resolveSiteProxyUrlByRequestUrl } = await import('./siteProxy.js');
expect(resolveSiteProxyUrlByRequestUrl('https://relay.example.com/openai/v1/models'))
expect(await resolveSiteProxyUrlByRequestUrl('https://relay.example.com/openai/v1/models'))
.toBe('http://127.0.0.1:7890');
expect(resolveSiteProxyUrlByRequestUrl('https://relay.example.com/v1/models'))
expect(await resolveSiteProxyUrlByRequestUrl('https://relay.example.com/v1/models'))
.toBe('http://127.0.0.1:7891');
});
it('injects dispatcher when proxy exists', async () => {
db.insert(schema.sites).values({
await db.insert(schema.sites).values({
name: 'proxy-site',
url: 'https://proxy-site.example.com',
platform: 'new-api',
@@ -62,7 +62,7 @@ describe('siteProxy', () => {
}).run();
const { withSiteProxyRequestInit } = await import('./siteProxy.js');
const requestInit = withSiteProxyRequestInit('https://proxy-site.example.com/v1/chat/completions', {
const requestInit = await withSiteProxyRequestInit('https://proxy-site.example.com/v1/chat/completions', {
method: 'POST',
});
+7 -7
View File
@@ -48,13 +48,13 @@ function normalizeSiteUrl(value: string): string {
}
}
function getCachedSiteProxyRows(nowMs = Date.now()): SiteProxyRow[] {
async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[]> {
if ((nowMs - siteProxyCache.loadedAt) < SITE_PROXY_CACHE_TTL_MS) {
return siteProxyCache.rows;
}
try {
const rows = db
const rows = await db
.select({
siteUrl: schema.sites.url,
proxyUrl: schema.sites.proxyUrl,
@@ -141,11 +141,11 @@ export function invalidateSiteProxyCache(): void {
siteProxyCache = { loadedAt: 0, rows: [] };
}
export function resolveSiteProxyUrlByRequestUrl(requestUrl: string): string | null {
export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promise<string | null> {
const normalizedRequestUrl = normalizeSiteUrl(requestUrl);
if (!normalizedRequestUrl) return null;
const rows = getCachedSiteProxyRows();
const rows = await getCachedSiteProxyRows();
let bestMatch: string | null = null;
let bestMatchLength = -1;
@@ -169,11 +169,11 @@ export function resolveSiteProxyUrlByRequestUrl(requestUrl: string): string | nu
return bestMatch;
}
export function withSiteProxyRequestInit(
export async function withSiteProxyRequestInit(
requestUrl: string,
options?: UndiciRequestInit,
): UndiciRequestInit {
const proxyUrl = resolveSiteProxyUrlByRequestUrl(requestUrl);
): Promise<UndiciRequestInit> {
const proxyUrl = await resolveSiteProxyUrlByRequestUrl(requestUrl);
if (!proxyUrl) return options ?? {};
const dispatcher = getDispatcherByProxyUrl(proxyUrl);
+17 -17
View File
@@ -33,12 +33,12 @@ describe('TokenRouter runtime cache', () => {
originalCacheTtlMs = config.tokenRouterCacheTtlMs;
});
beforeEach(() => {
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
config.tokenRouterCacheTtlMs = 60_000;
invalidateTokenRouterCache();
});
@@ -49,15 +49,15 @@ describe('TokenRouter runtime cache', () => {
delete process.env.DATA_DIR;
});
it('keeps route snapshot inside TTL until explicit invalidation', () => {
const site = db.insert(schema.sites).values({
it('keeps route snapshot inside TTL until explicit invalidation', async () => {
const site = await db.insert(schema.sites).values({
name: 'cache-site',
url: 'https://cache-site.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'cache-user',
accessToken: 'cache-access-token',
@@ -65,7 +65,7 @@ describe('TokenRouter runtime cache', () => {
status: 'active',
}).returning().get();
const token = db.insert(schema.accountTokens).values({
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'cache-token',
token: 'sk-cache-token',
@@ -73,12 +73,12 @@ describe('TokenRouter runtime cache', () => {
isDefault: true,
}).returning().get();
const route = db.insert(schema.tokenRoutes).values({
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
enabled: true,
}).returning().get();
db.insert(schema.routeChannels).values({
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: token.id,
@@ -88,16 +88,16 @@ describe('TokenRouter runtime cache', () => {
}).run();
const router = new TokenRouter();
expect(router.selectChannel('gpt-4o-mini')).toBeTruthy();
expect(await router.selectChannel('gpt-4o-mini')).toBeTruthy();
db.delete(schema.routeChannels).where(eq(schema.routeChannels.routeId, route.id)).run();
db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run();
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.routeId, route.id)).run();
await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run();
const cachedSelection = router.selectChannel('gpt-4o-mini');
const cachedSelection = await router.selectChannel('gpt-4o-mini');
expect(cachedSelection).toBeTruthy();
invalidateTokenRouterCache();
const refreshedSelection = router.selectChannel('gpt-4o-mini');
const refreshedSelection = await router.selectChannel('gpt-4o-mini');
expect(refreshedSelection).toBeNull();
});
});
@@ -26,12 +26,12 @@ describe('TokenRouter downstream policy', () => {
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
});
beforeEach(() => {
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
invalidateTokenRouterCache();
});
@@ -40,15 +40,15 @@ describe('TokenRouter downstream policy', () => {
delete process.env.DATA_DIR;
});
it('respects allowedRouteIds when selecting channels', () => {
const site = db.insert(schema.sites).values({
it('respects allowedRouteIds when selecting channels', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-a',
url: 'https://a.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'user-a',
accessToken: 'access-a',
@@ -56,17 +56,17 @@ describe('TokenRouter downstream policy', () => {
status: 'active',
}).returning().get();
const routeAllowed = db.insert(schema.tokenRoutes).values({
const routeAllowed = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-opus-4-6',
enabled: true,
}).returning().get();
const routeBlocked = db.insert(schema.tokenRoutes).values({
const routeBlocked = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
enabled: true,
}).returning().get();
db.insert(schema.routeChannels).values({
await db.insert(schema.routeChannels).values({
routeId: routeAllowed.id,
accountId: account.id,
tokenId: null,
@@ -75,7 +75,7 @@ describe('TokenRouter downstream policy', () => {
enabled: true,
}).run();
db.insert(schema.routeChannels).values({
await db.insert(schema.routeChannels).values({
routeId: routeBlocked.id,
accountId: account.id,
tokenId: null,
@@ -86,12 +86,12 @@ describe('TokenRouter downstream policy', () => {
const router = new TokenRouter();
const allowedPick = router.selectChannel('claude-opus-4-6', {
const allowedPick = await router.selectChannel('claude-opus-4-6', {
allowedRouteIds: [routeAllowed.id],
supportedModels: [],
siteWeightMultipliers: {},
});
const blockedPick = router.selectChannel('gpt-4o-mini', {
const blockedPick = await router.selectChannel('gpt-4o-mini', {
allowedRouteIds: [routeAllowed.id],
supportedModels: [],
siteWeightMultipliers: {},
@@ -102,22 +102,22 @@ describe('TokenRouter downstream policy', () => {
expect(blockedPick).toBeNull();
});
it('applies site weight multipliers to probability explanation', () => {
const siteHigh = db.insert(schema.sites).values({
it('applies site weight multipliers to probability explanation', async () => {
const siteHigh = await db.insert(schema.sites).values({
name: 'high-site',
url: 'https://high.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const siteLow = db.insert(schema.sites).values({
const siteLow = await db.insert(schema.sites).values({
name: 'low-site',
url: 'https://low.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const accountHigh = db.insert(schema.accounts).values({
const accountHigh = await db.insert(schema.accounts).values({
siteId: siteHigh.id,
username: 'user-high',
accessToken: 'access-high',
@@ -127,7 +127,7 @@ describe('TokenRouter downstream policy', () => {
balance: 100,
}).returning().get();
const accountLow = db.insert(schema.accounts).values({
const accountLow = await db.insert(schema.accounts).values({
siteId: siteLow.id,
username: 'user-low',
accessToken: 'access-low',
@@ -137,12 +137,12 @@ describe('TokenRouter downstream policy', () => {
balance: 100,
}).returning().get();
const route = db.insert(schema.tokenRoutes).values({
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-sonnet-4-6',
enabled: true,
}).returning().get();
const channelHigh = db.insert(schema.routeChannels).values({
const channelHigh = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountHigh.id,
tokenId: null,
@@ -151,7 +151,7 @@ describe('TokenRouter downstream policy', () => {
enabled: true,
}).returning().get();
const channelLow = db.insert(schema.routeChannels).values({
const channelLow = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountLow.id,
tokenId: null,
@@ -161,7 +161,7 @@ describe('TokenRouter downstream policy', () => {
}).returning().get();
const router = new TokenRouter();
const decision = router.explainSelectionForRoute(
const decision = await router.explainSelectionForRoute(
route.id,
'claude-sonnet-4-6',
[],
@@ -183,8 +183,8 @@ describe('TokenRouter downstream policy', () => {
expect((highCandidate?.probability || 0)).toBeGreaterThan(lowCandidate?.probability || 0);
});
it('combines site global weight with downstream site multiplier', () => {
const siteGlobalHigh = db.insert(schema.sites).values({
it('combines site global weight with downstream site multiplier', async () => {
const siteGlobalHigh = await db.insert(schema.sites).values({
name: 'global-high-site',
url: 'https://global-high.example.com',
platform: 'new-api',
@@ -192,7 +192,7 @@ describe('TokenRouter downstream policy', () => {
globalWeight: 3,
}).returning().get();
const siteGlobalLow = db.insert(schema.sites).values({
const siteGlobalLow = await db.insert(schema.sites).values({
name: 'global-low-site',
url: 'https://global-low.example.com',
platform: 'new-api',
@@ -200,7 +200,7 @@ describe('TokenRouter downstream policy', () => {
globalWeight: 1,
}).returning().get();
const accountGlobalHigh = db.insert(schema.accounts).values({
const accountGlobalHigh = await db.insert(schema.accounts).values({
siteId: siteGlobalHigh.id,
username: 'user-global-high',
accessToken: 'access-global-high',
@@ -210,7 +210,7 @@ describe('TokenRouter downstream policy', () => {
balance: 100,
}).returning().get();
const accountGlobalLow = db.insert(schema.accounts).values({
const accountGlobalLow = await db.insert(schema.accounts).values({
siteId: siteGlobalLow.id,
username: 'user-global-low',
accessToken: 'access-global-low',
@@ -220,12 +220,12 @@ describe('TokenRouter downstream policy', () => {
balance: 100,
}).returning().get();
const route = db.insert(schema.tokenRoutes).values({
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5-mini',
enabled: true,
}).returning().get();
const highChannel = db.insert(schema.routeChannels).values({
const highChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountGlobalHigh.id,
tokenId: null,
@@ -234,7 +234,7 @@ describe('TokenRouter downstream policy', () => {
enabled: true,
}).returning().get();
const lowChannel = db.insert(schema.routeChannels).values({
const lowChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountGlobalLow.id,
tokenId: null,
@@ -244,7 +244,7 @@ describe('TokenRouter downstream policy', () => {
}).returning().get();
const router = new TokenRouter();
const decision = router.explainSelectionForRoute(
const decision = await router.explainSelectionForRoute(
route.id,
'gpt-5-mini',
[],
@@ -267,15 +267,15 @@ describe('TokenRouter downstream policy', () => {
expect((highCandidate?.probability || 0)).toBeGreaterThan(lowCandidate?.probability || 0);
});
it('supports union semantics between supportedModels and allowedRouteIds', () => {
const site = db.insert(schema.sites).values({
it('supports union semantics between supportedModels and allowedRouteIds', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-union',
url: 'https://union.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'user-union',
accessToken: 'access-union',
@@ -283,17 +283,17 @@ describe('TokenRouter downstream policy', () => {
status: 'active',
}).returning().get();
const claudeGroupRoute = db.insert(schema.tokenRoutes).values({
const claudeGroupRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
enabled: true,
}).returning().get();
const gptExactRoute = db.insert(schema.tokenRoutes).values({
const gptExactRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
enabled: true,
}).returning().get();
db.insert(schema.routeChannels).values({
await db.insert(schema.routeChannels).values({
routeId: claudeGroupRoute.id,
accountId: account.id,
tokenId: null,
@@ -302,7 +302,7 @@ describe('TokenRouter downstream policy', () => {
enabled: true,
}).run();
db.insert(schema.routeChannels).values({
await db.insert(schema.routeChannels).values({
routeId: gptExactRoute.id,
accountId: account.id,
tokenId: null,
@@ -318,8 +318,8 @@ describe('TokenRouter downstream policy', () => {
siteWeightMultipliers: {},
};
const claudePick = router.selectChannel('claude-opus-4-6', policy);
const gptPick = router.selectChannel('gpt-4o-mini', policy);
const claudePick = await router.selectChannel('claude-opus-4-6', policy);
const gptPick = await router.selectChannel('gpt-4o-mini', policy);
expect(claudePick?.channel.routeId).toBe(claudeGroupRoute.id);
expect(gptPick?.channel.routeId).toBe(gptExactRoute.id);
@@ -32,13 +32,13 @@ describe('TokenRouter patterns and model mapping', () => {
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
});
beforeEach(() => {
beforeEach(async () => {
idSeed = 0;
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
invalidateTokenRouterCache();
});
@@ -47,9 +47,9 @@ describe('TokenRouter patterns and model mapping', () => {
delete process.env.DATA_DIR;
});
function createSite(namePrefix: string) {
async function createSite(namePrefix: string) {
const id = nextId();
return db.insert(schema.sites).values({
return await db.insert(schema.sites).values({
name: `${namePrefix}-${id}`,
url: `https://${namePrefix}-${id}.example.com`,
platform: 'new-api',
@@ -57,9 +57,9 @@ describe('TokenRouter patterns and model mapping', () => {
}).returning().get();
}
function createAccount(siteId: number, usernamePrefix: string) {
async function createAccount(siteId: number, usernamePrefix: string) {
const id = nextId();
return db.insert(schema.accounts).values({
return await db.insert(schema.accounts).values({
siteId,
username: `${usernamePrefix}-${id}`,
accessToken: `access-${id}`,
@@ -68,20 +68,20 @@ describe('TokenRouter patterns and model mapping', () => {
}).returning().get();
}
function createRouteWithSingleChannel(
async function createRouteWithSingleChannel(
modelPattern: string,
modelMapping?: string,
options?: { displayName?: string; sourceModel?: string | null },
) {
const site = createSite('pattern-site');
const account = createAccount(site.id, 'pattern-user');
const route = db.insert(schema.tokenRoutes).values({
const site = await createSite('pattern-site');
const account = await createAccount(site.id, 'pattern-user');
const route = await db.insert(schema.tokenRoutes).values({
modelPattern,
displayName: options?.displayName,
modelMapping,
enabled: true,
}).returning().get();
const channel = db.insert(schema.routeChannels).values({
const channel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: null,
@@ -93,49 +93,49 @@ describe('TokenRouter patterns and model mapping', () => {
return { route, channel };
}
it('matches routes with re: regex patterns', () => {
createRouteWithSingleChannel('re:^claude-(opus|sonnet)-4-6$');
it('matches routes with re: regex patterns', async () => {
await createRouteWithSingleChannel('re:^claude-(opus|sonnet)-4-6$');
const router = new TokenRouter();
const matched = router.selectChannel('claude-opus-4-6');
const unmatched = router.selectChannel('claude-haiku-4-6');
const matched = await router.selectChannel('claude-opus-4-6');
const unmatched = await router.selectChannel('claude-haiku-4-6');
expect(matched).toBeTruthy();
expect(matched?.actualModel).toBe('claude-opus-4-6');
expect(unmatched).toBeNull();
});
it('ignores invalid re: patterns and falls back to next matched route', () => {
const invalid = createRouteWithSingleChannel('re:([a-z');
const glob = createRouteWithSingleChannel('claude-*');
it('ignores invalid re: patterns and falls back to next matched route', async () => {
const invalid = await createRouteWithSingleChannel('re:([a-z');
const glob = await createRouteWithSingleChannel('claude-*');
const router = new TokenRouter();
const selected = router.selectChannel('claude-opus-4-6');
const selected = await router.selectChannel('claude-opus-4-6');
expect(selected).toBeTruthy();
expect(selected?.channel.id).toBe(glob.channel.id);
expect(selected?.channel.id).not.toBe(invalid.channel.id);
});
it('supports exact, glob and re: keys in modelMapping with exact taking precedence', () => {
it('supports exact, glob and re: keys in modelMapping with exact taking precedence', async () => {
const mapping = JSON.stringify({
'claude-sonnet-4-6': 'target-exact',
'claude-sonnet-*': 'target-glob',
're:^gpt-4o-mini-\\d+$': 'target-regex',
});
createRouteWithSingleChannel('*', mapping);
await createRouteWithSingleChannel('*', mapping);
const router = new TokenRouter();
const exact = router.selectChannel('claude-sonnet-4-6');
const glob = router.selectChannel('claude-sonnet-4-7');
const regex = router.selectChannel('gpt-4o-mini-20250101');
const exact = await router.selectChannel('claude-sonnet-4-6');
const glob = await router.selectChannel('claude-sonnet-4-7');
const regex = await router.selectChannel('gpt-4o-mini-20250101');
expect(exact?.actualModel).toBe('target-exact');
expect(glob?.actualModel).toBe('target-glob');
expect(regex?.actualModel).toBe('target-regex');
});
it('matches a route by display name alias as an exposed model', () => {
createRouteWithSingleChannel(
it('matches a route by display name alias as an exposed model', async () => {
await createRouteWithSingleChannel(
're:^claude-(opus|sonnet)-4-5$',
undefined,
{
@@ -145,9 +145,9 @@ describe('TokenRouter patterns and model mapping', () => {
);
const router = new TokenRouter();
const selected = router.selectChannel('claude-opus-4-6');
const decision = router.explainSelection('claude-opus-4-6');
const exposedModels = router.getAvailableModels();
const selected = await router.selectChannel('claude-opus-4-6');
const decision = await router.explainSelection('claude-opus-4-6');
const exposedModels = await router.getAvailableModels();
expect(selected).toBeTruthy();
expect(selected?.actualModel).toBe('claude-opus-4-5');
@@ -52,15 +52,15 @@ describe('TokenRouter selection scoring', () => {
originalRoutingFallbackUnitCost = config.routingFallbackUnitCost;
});
beforeEach(() => {
beforeEach(async () => {
idSeed = 0;
mockedCatalogRoutingCost.mockReset();
mockedCatalogRoutingCost.mockReturnValue(null);
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
invalidateTokenRouterCache();
});
@@ -71,16 +71,16 @@ describe('TokenRouter selection scoring', () => {
delete process.env.DATA_DIR;
});
function createRoute(modelPattern: string) {
return db.insert(schema.tokenRoutes).values({
async function createRoute(modelPattern: string) {
return await db.insert(schema.tokenRoutes).values({
modelPattern,
enabled: true,
}).returning().get();
}
function createSite(namePrefix: string) {
async function createSite(namePrefix: string) {
const id = nextId();
return db.insert(schema.sites).values({
return await db.insert(schema.sites).values({
name: `${namePrefix}-${id}`,
url: `https://${namePrefix}-${id}.example.com`,
platform: 'new-api',
@@ -88,9 +88,9 @@ describe('TokenRouter selection scoring', () => {
}).returning().get();
}
function createAccount(siteId: number, usernamePrefix: string) {
async function createAccount(siteId: number, usernamePrefix: string) {
const id = nextId();
return db.insert(schema.accounts).values({
return await db.insert(schema.accounts).values({
siteId,
username: `${usernamePrefix}-${id}`,
accessToken: `access-${id}`,
@@ -99,8 +99,8 @@ describe('TokenRouter selection scoring', () => {
}).returning().get();
}
function createToken(accountId: number, name: string) {
return db.insert(schema.accountTokens).values({
async function createToken(accountId: number, name: string) {
return await db.insert(schema.accountTokens).values({
accountId,
name,
token: `token-${name}-${nextId()}`,
@@ -109,7 +109,7 @@ describe('TokenRouter selection scoring', () => {
}).returning().get();
}
it('normalizes probability across channels on the same site', () => {
it('normalizes probability across channels on the same site', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
@@ -118,18 +118,18 @@ describe('TokenRouter selection scoring', () => {
usageWeight: 0,
};
const route = createRoute('claude-haiku-4-5-20251001');
const route = await createRoute('claude-haiku-4-5-20251001');
const siteA = createSite('site-a');
const accountA = createAccount(siteA.id, 'user-a');
const tokenA1 = createToken(accountA.id, 'a-1');
const tokenA2 = createToken(accountA.id, 'a-2');
const siteA = await createSite('site-a');
const accountA = await createAccount(siteA.id, 'user-a');
const tokenA1 = await createToken(accountA.id, 'a-1');
const tokenA2 = await createToken(accountA.id, 'a-2');
const siteB = createSite('site-b');
const accountB = createAccount(siteB.id, 'user-b');
const tokenB = createToken(accountB.id, 'b-1');
const siteB = await createSite('site-b');
const accountB = await createAccount(siteB.id, 'user-b');
const tokenB = await createToken(accountB.id, 'b-1');
const channelA1 = db.insert(schema.routeChannels).values({
const channelA1 = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA1.id,
@@ -138,7 +138,7 @@ describe('TokenRouter selection scoring', () => {
enabled: true,
}).returning().get();
const channelA2 = db.insert(schema.routeChannels).values({
const channelA2 = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA2.id,
@@ -147,7 +147,7 @@ describe('TokenRouter selection scoring', () => {
enabled: true,
}).returning().get();
const channelB = db.insert(schema.routeChannels).values({
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
@@ -156,7 +156,7 @@ describe('TokenRouter selection scoring', () => {
enabled: true,
}).returning().get();
const decision = new TokenRouter().explainSelection('claude-haiku-4-5-20251001');
const decision = await new TokenRouter().explainSelection('claude-haiku-4-5-20251001');
const probMap = new Map(decision.candidates.map((candidate) => [candidate.channelId, candidate.probability]));
const probA1 = probMap.get(channelA1.id) ?? 0;
@@ -169,7 +169,7 @@ describe('TokenRouter selection scoring', () => {
expect(probA1 + probA2).toBeCloseTo(probB, 1);
});
it('uses observed channel cost from real routing results when scoring cost priority', () => {
it('uses observed channel cost from real routing results when scoring cost priority', async () => {
config.routingWeights = {
baseWeightFactor: 0.35,
valueScoreFactor: 0.65,
@@ -178,12 +178,12 @@ describe('TokenRouter selection scoring', () => {
usageWeight: 0,
};
const route = createRoute('claude-opus-4-6');
const route = await createRoute('claude-opus-4-6');
const siteCheap = createSite('cheap-site');
const accountCheap = createAccount(siteCheap.id, 'cheap-user');
const tokenCheap = createToken(accountCheap.id, 'cheap-token');
db.insert(schema.routeChannels).values({
const siteCheap = await createSite('cheap-site');
const accountCheap = await createAccount(siteCheap.id, 'cheap-user');
const tokenCheap = await createToken(accountCheap.id, 'cheap-token');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountCheap.id,
tokenId: tokenCheap.id,
@@ -195,10 +195,10 @@ describe('TokenRouter selection scoring', () => {
totalCost: 0.01,
}).run();
const siteExpensive = createSite('expensive-site');
const accountExpensive = createAccount(siteExpensive.id, 'expensive-user');
const tokenExpensive = createToken(accountExpensive.id, 'exp-token');
db.insert(schema.routeChannels).values({
const siteExpensive = await createSite('expensive-site');
const accountExpensive = await createAccount(siteExpensive.id, 'expensive-user');
const tokenExpensive = await createToken(accountExpensive.id, 'exp-token');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountExpensive.id,
tokenId: tokenExpensive.id,
@@ -210,7 +210,7 @@ describe('TokenRouter selection scoring', () => {
totalCost: 0.1,
}).run();
const decision = new TokenRouter().explainSelection('claude-opus-4-6');
const decision = await new TokenRouter().explainSelection('claude-opus-4-6');
const cheapCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('cheap-site'));
const expensiveCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('expensive-site'));
@@ -221,7 +221,7 @@ describe('TokenRouter selection scoring', () => {
expect(expensiveCandidate?.reason || '').toContain('成本=实测');
});
it('uses runtime-configured fallback unit cost when observed and configured costs are missing', () => {
it('uses runtime-configured fallback unit cost when observed and configured costs are missing', async () => {
config.routingWeights = {
baseWeightFactor: 0.35,
valueScoreFactor: 0.65,
@@ -231,12 +231,12 @@ describe('TokenRouter selection scoring', () => {
};
config.routingFallbackUnitCost = 0.02;
const route = createRoute('claude-sonnet-4-6');
const route = await createRoute('claude-sonnet-4-6');
const siteFallback = createSite('fallback-site');
const accountFallback = createAccount(siteFallback.id, 'fallback-user');
const tokenFallback = createToken(accountFallback.id, 'fallback-token');
db.insert(schema.routeChannels).values({
const siteFallback = await createSite('fallback-site');
const accountFallback = await createAccount(siteFallback.id, 'fallback-user');
const tokenFallback = await createToken(accountFallback.id, 'fallback-token');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountFallback.id,
tokenId: tokenFallback.id,
@@ -248,10 +248,10 @@ describe('TokenRouter selection scoring', () => {
totalCost: 0,
}).run();
const siteObserved = createSite('observed-site');
const accountObserved = createAccount(siteObserved.id, 'observed-user');
const tokenObserved = createToken(accountObserved.id, 'observed-token');
db.insert(schema.routeChannels).values({
const siteObserved = await createSite('observed-site');
const accountObserved = await createAccount(siteObserved.id, 'observed-user');
const tokenObserved = await createToken(accountObserved.id, 'observed-token');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountObserved.id,
tokenId: tokenObserved.id,
@@ -263,7 +263,7 @@ describe('TokenRouter selection scoring', () => {
totalCost: 2, // unit cost 0.2
}).run();
const decision = new TokenRouter().explainSelection('claude-sonnet-4-6');
const decision = await new TokenRouter().explainSelection('claude-sonnet-4-6');
const fallbackCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('fallback-site'));
const observedCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('observed-site'));
@@ -273,7 +273,7 @@ describe('TokenRouter selection scoring', () => {
expect(fallbackCandidate?.reason || '').toContain('成本=默认:0.020000');
});
it('penalizes fallback-cost channels when fallback unit cost is set very high', () => {
it('penalizes fallback-cost channels when fallback unit cost is set very high', async () => {
config.routingWeights = {
baseWeightFactor: 0.35,
valueScoreFactor: 0.65,
@@ -283,10 +283,10 @@ describe('TokenRouter selection scoring', () => {
};
config.routingFallbackUnitCost = 1000;
const route = createRoute('gpt-5-nano');
const route = await createRoute('gpt-5-nano');
const siteFallback = createSite('fallback-high-balance');
const accountFallback = db.insert(schema.accounts).values({
const siteFallback = await createSite('fallback-high-balance');
const accountFallback = await db.insert(schema.accounts).values({
siteId: siteFallback.id,
username: `fallback-high-balance-${nextId()}`,
accessToken: `access-${nextId()}`,
@@ -294,8 +294,8 @@ describe('TokenRouter selection scoring', () => {
status: 'active',
balance: 10_000,
}).returning().get();
const tokenFallback = createToken(accountFallback.id, 'fallback-token');
db.insert(schema.routeChannels).values({
const tokenFallback = await createToken(accountFallback.id, 'fallback-token');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountFallback.id,
tokenId: tokenFallback.id,
@@ -307,8 +307,8 @@ describe('TokenRouter selection scoring', () => {
totalCost: 0,
}).run();
const siteObserved = createSite('observed-low-balance');
const accountObserved = db.insert(schema.accounts).values({
const siteObserved = await createSite('observed-low-balance');
const accountObserved = await db.insert(schema.accounts).values({
siteId: siteObserved.id,
username: `observed-low-balance-${nextId()}`,
accessToken: `access-${nextId()}`,
@@ -316,8 +316,8 @@ describe('TokenRouter selection scoring', () => {
status: 'active',
balance: 0,
}).returning().get();
const tokenObserved = createToken(accountObserved.id, 'observed-token');
db.insert(schema.routeChannels).values({
const tokenObserved = await createToken(accountObserved.id, 'observed-token');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountObserved.id,
tokenId: tokenObserved.id,
@@ -329,7 +329,7 @@ describe('TokenRouter selection scoring', () => {
totalCost: 10, // observed unit cost = 1
}).run();
const decision = new TokenRouter().explainSelection('gpt-5-nano');
const decision = await new TokenRouter().explainSelection('gpt-5-nano');
const fallbackCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('fallback-high-balance'));
const observedCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('observed-low-balance'));
@@ -340,7 +340,7 @@ describe('TokenRouter selection scoring', () => {
expect(fallbackCandidate?.reason || '').toContain('成本=默认:1000.000000');
});
it('uses cached catalog routing cost when observed and configured costs are missing', () => {
it('uses cached catalog routing cost when observed and configured costs are missing', async () => {
config.routingWeights = {
baseWeightFactor: 0.35,
valueScoreFactor: 0.65,
@@ -350,12 +350,12 @@ describe('TokenRouter selection scoring', () => {
};
config.routingFallbackUnitCost = 100;
const route = createRoute('claude-sonnet-4-5-20250929');
const route = await createRoute('claude-sonnet-4-5-20250929');
const siteCatalog = createSite('catalog-site');
const accountCatalog = createAccount(siteCatalog.id, 'catalog-user');
const tokenCatalog = createToken(accountCatalog.id, 'catalog-token');
db.insert(schema.routeChannels).values({
const siteCatalog = await createSite('catalog-site');
const accountCatalog = await createAccount(siteCatalog.id, 'catalog-user');
const tokenCatalog = await createToken(accountCatalog.id, 'catalog-token');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountCatalog.id,
tokenId: tokenCatalog.id,
@@ -367,10 +367,10 @@ describe('TokenRouter selection scoring', () => {
totalCost: 0,
}).run();
const siteFallback = createSite('fallback-site');
const accountFallback = createAccount(siteFallback.id, 'fallback-user');
const tokenFallback = createToken(accountFallback.id, 'fallback-token');
db.insert(schema.routeChannels).values({
const siteFallback = await createSite('fallback-site');
const accountFallback = await createAccount(siteFallback.id, 'fallback-user');
const tokenFallback = await createToken(accountFallback.id, 'fallback-token');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountFallback.id,
tokenId: tokenFallback.id,
@@ -388,7 +388,7 @@ describe('TokenRouter selection scoring', () => {
return 0.2;
});
const decision = new TokenRouter().explainSelection('claude-sonnet-4-5-20250929');
const decision = await new TokenRouter().explainSelection('claude-sonnet-4-5-20250929');
const catalogCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('catalog-site'));
const fallbackCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('fallback-site'));
@@ -27,14 +27,14 @@ describe('TokenRouter site status guard', () => {
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
});
beforeEach(() => {
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();
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
invalidateTokenRouterCache();
});
@@ -43,15 +43,15 @@ describe('TokenRouter site status guard', () => {
delete process.env.DATA_DIR;
});
it('does not select channels from disabled sites', () => {
const site = db.insert(schema.sites).values({
it('does not select channels from disabled sites', async () => {
const site = await db.insert(schema.sites).values({
name: 'disabled-site',
url: 'https://disabled.example.com',
platform: 'new-api',
}).returning().get();
db.run(sql`update sites set status = 'disabled' where id = ${site.id}`);
await db.run(sql`update sites set status = 'disabled' where id = ${site.id}`);
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'disabled-user',
accessToken: 'access-disabled',
@@ -59,12 +59,12 @@ describe('TokenRouter site status guard', () => {
status: 'active',
}).returning().get();
const route = db.insert(schema.tokenRoutes).values({
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
enabled: true,
}).returning().get();
const channel = db.insert(schema.routeChannels).values({
const channel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: null,
@@ -75,10 +75,10 @@ describe('TokenRouter site status guard', () => {
}).returning().get();
const router = new TokenRouter();
const selected = router.selectChannel('gpt-4o-mini');
const selected = await router.selectChannel('gpt-4o-mini');
expect(selected).toBeNull();
const decision = router.explainSelection('gpt-4o-mini');
const decision = await router.explainSelection('gpt-4o-mini');
expect(decision.matched).toBe(true);
const candidate = decision.candidates.find((item) => item.channelId === channel.id);
expect(candidate?.eligible).toBe(false);
+59 -59
View File
@@ -64,14 +64,14 @@ function isCacheFresh(loadedAt: number, nowMs: number): boolean {
return nowMs - loadedAt < resolveTokenRouterCacheTtlMs();
}
function loadEnabledRoutes(nowMs = Date.now()): RouteRow[] {
async function loadEnabledRoutes(nowMs = Date.now()): Promise<RouteRow[]> {
if (isCacheFresh(routeCacheSnapshot.loadedAt, nowMs)) {
return routeCacheSnapshot.routes;
}
const routes = db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.enabled, true))
.all();
const routes = await db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.enabled, true))
.all();
routeCacheSnapshot = {
loadedAt: nowMs,
routes,
@@ -79,17 +79,17 @@ function loadEnabledRoutes(nowMs = Date.now()): RouteRow[] {
return routes;
}
function loadRouteMatch(route: RouteRow, nowMs = Date.now()): RouteMatch {
async function loadRouteMatch(route: RouteRow, nowMs = Date.now()): Promise<RouteMatch> {
const cached = routeMatchCache.get(route.id);
if (cached && isCacheFresh(cached.loadedAt, nowMs)) {
return cached.match;
}
const channels = 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))
const channels = await 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(eq(schema.routeChannels.routeId, route.id))
.all();
@@ -364,15 +364,15 @@ function resolveEffectiveUnitCost(candidate: RouteChannelCandidate, modelName: s
};
}
export class TokenRouter {
export class TokenRouter {
/**
* Find matching route and select a channel for the given model.
* Returns null if no route/channel available.
*/
selectChannel(requestedModel: string, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): SelectedChannel | null {
async selectChannel(requestedModel: string, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): Promise<SelectedChannel | null> {
if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null;
const match = this.findRoute(requestedModel, downstreamPolicy);
const match = await this.findRoute(requestedModel, downstreamPolicy);
if (!match) return null;
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
@@ -443,14 +443,14 @@ export class TokenRouter {
/**
* Select next channel for failover (exclude already-tried channels).
*/
selectNextChannel(
async selectNextChannel(
requestedModel: string,
excludeChannelIds: number[],
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
): SelectedChannel | null {
): Promise<SelectedChannel | null> {
if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null;
const match = this.findRoute(requestedModel, downstreamPolicy);
const match = await this.findRoute(requestedModel, downstreamPolicy);
if (!match) return null;
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
@@ -514,30 +514,30 @@ export class TokenRouter {
return null;
}
explainSelection(
async explainSelection(
requestedModel: string,
excludeChannelIds: number[] = [],
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
): RouteDecisionExplanation {
const match = this.findRoute(requestedModel, downstreamPolicy);
return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy });
}
explainSelectionForRoute(
): Promise<RouteDecisionExplanation> {
const match = await this.findRoute(requestedModel, downstreamPolicy);
return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy });
}
async explainSelectionForRoute(
routeId: number,
requestedModel: string,
excludeChannelIds: number[] = [],
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
): RouteDecisionExplanation {
const match = this.findRouteById(routeId, downstreamPolicy);
return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy });
}
explainSelectionRouteWide(routeId: number, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): RouteDecisionExplanation {
const match = this.findRouteById(routeId, downstreamPolicy);
const fallbackRequestedModel = match?.route.modelPattern || `route:${routeId}`;
return this.explainSelectionFromMatch(match, fallbackRequestedModel, {
bypassSourceModelCheck: true,
): Promise<RouteDecisionExplanation> {
const match = await this.findRouteById(routeId, downstreamPolicy);
return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy });
}
async explainSelectionRouteWide(routeId: number, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): Promise<RouteDecisionExplanation> {
const match = await this.findRouteById(routeId, downstreamPolicy);
const fallbackRequestedModel = match?.route.modelPattern || `route:${routeId}`;
return this.explainSelectionFromMatch(match, fallbackRequestedModel, {
bypassSourceModelCheck: true,
useChannelSourceModelForCost: true,
downstreamPolicy,
});
@@ -720,14 +720,14 @@ export class TokenRouter {
/**
* Record success for a channel.
*/
recordSuccess(channelId: number, latencyMs: number, cost: number) {
const ch = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
async recordSuccess(channelId: number, latencyMs: number, cost: number) {
const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!ch) return;
const nowIso = new Date().toISOString();
const nextSuccessCount = (ch.successCount ?? 0) + 1;
const nextTotalLatencyMs = (ch.totalLatencyMs ?? 0) + latencyMs;
const nextTotalCost = (ch.totalCost ?? 0) + cost;
db.update(schema.routeChannels).set({
await db.update(schema.routeChannels).set({
successCount: nextSuccessCount,
totalLatencyMs: nextTotalLatencyMs,
totalCost: nextTotalCost,
@@ -749,15 +749,15 @@ export class TokenRouter {
/**
* Record failure and set cooldown.
*/
recordFailure(channelId: number) {
const ch = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
async recordFailure(channelId: number) {
const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!ch) return;
const failCount = (ch.failCount ?? 0) + 1;
// Exponential backoff cooldown: 30s, 60s, 120s, 240s, max 5min
const cooldownSec = Math.min(30 * Math.pow(2, failCount - 1), 300);
const cooldownUntil = new Date(Date.now() + cooldownSec * 1000).toISOString();
const nowIso = new Date().toISOString();
db.update(schema.routeChannels).set({
await db.update(schema.routeChannels).set({
failCount,
lastFailAt: nowIso,
cooldownUntil,
@@ -773,18 +773,18 @@ export class TokenRouter {
/**
* Get all available models (aggregated from all routes).
*/
getAvailableModels(): string[] {
const routes = loadEnabledRoutes();
const exposed = routes
.map((route) => getExposedModelNameForRoute(route).trim())
.filter((name) => name.length > 0);
async getAvailableModels(): Promise<string[]> {
const routes = await loadEnabledRoutes();
const exposed = routes
.map((route) => getExposedModelNameForRoute(route).trim())
.filter((name) => name.length > 0);
return Array.from(new Set(exposed));
}
// --- Private methods ---
private findRoute(model: string, downstreamPolicy: DownstreamRoutingPolicy): RouteMatch | null {
let routes = loadEnabledRoutes();
private async findRoute(model: string, downstreamPolicy: DownstreamRoutingPolicy): Promise<RouteMatch | null> {
let routes = await loadEnabledRoutes();
const supportedPatterns = Array.isArray(downstreamPolicy.supportedModels)
? downstreamPolicy.supportedModels
@@ -803,23 +803,23 @@ export class TokenRouter {
if (!matchedRoute) return null;
return this.loadRouteMatch(matchedRoute);
}
private findRouteById(routeId: number, downstreamPolicy: DownstreamRoutingPolicy): RouteMatch | null {
return await this.loadRouteMatch(matchedRoute);
}
private async findRouteById(routeId: number, downstreamPolicy: DownstreamRoutingPolicy): Promise<RouteMatch | null> {
if (downstreamPolicy.allowedRouteIds.length > 0 && !downstreamPolicy.allowedRouteIds.includes(routeId)) {
return null;
}
const route = loadEnabledRoutes().find((item) => item.id === routeId);
if (!route) return null;
return this.loadRouteMatch(route);
}
private loadRouteMatch(route: typeof schema.tokenRoutes.$inferSelect): RouteMatch {
return loadRouteMatch(route);
}
const route = (await loadEnabledRoutes()).find((item) => item.id === routeId);
if (!route) return null;
return await this.loadRouteMatch(route);
}
private async loadRouteMatch(route: typeof schema.tokenRoutes.$inferSelect): Promise<RouteMatch> {
return await loadRouteMatch(route);
}
private resolveChannelTokenValue(candidate: {
channel: typeof schema.routeChannels.$inferSelect;