Merge remote-tracking branch 'origin/main' into feat/account-level-proxy

# Conflicts:
#	src/server/proxy-core/surfaces/chatSurface.ts
#	src/server/proxy-core/surfaces/openAiResponsesSurface.ts
#	src/server/routes/proxy/embeddings.ts
#	src/server/routes/proxy/images.ts
#	src/server/services/modelService.ts
This commit is contained in:
Hureru
2026-03-21 10:09:19 +08:00
198 changed files with 20039 additions and 1105 deletions
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest';
import {
buildStoredSub2ApiSubscriptionSummary,
getCredentialModeFromExtraConfig,
getPlatformUserIdFromExtraConfig,
getProxyUrlFromExtraConfig,
getSub2ApiAuthFromExtraConfig,
getSub2ApiSubscriptionFromExtraConfig,
guessPlatformUserIdFromUsername,
mergeAccountExtraConfig,
normalizeCredentialMode,
@@ -120,4 +122,37 @@ describe('accountExtraConfig', () => {
extraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toBe(true);
});
it('parses stored sub2api subscription summary from extra config', () => {
const extraConfig = mergeAccountExtraConfig(null, {
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
activeCount: 1,
totalUsedUsd: 3.5,
subscriptions: [
{
id: 7,
groupName: 'Pro',
expiresAt: '2026-04-01T00:00:00.000Z',
monthlyUsedUsd: 3.5,
monthlyLimitUsd: 20,
},
],
}, 1760000000000),
});
expect(getSub2ApiSubscriptionFromExtraConfig(extraConfig)).toEqual({
activeCount: 1,
totalUsedUsd: 3.5,
subscriptions: [
{
id: 7,
groupName: 'Pro',
expiresAt: '2026-04-01T00:00:00.000Z',
monthlyUsedUsd: 3.5,
monthlyLimitUsd: 20,
},
],
updatedAt: 1760000000000,
});
});
});
+144
View File
@@ -1,3 +1,5 @@
import type { SubscriptionPlanSummary, SubscriptionSummary } from './platforms/base.js';
type AutoReloginConfig = {
username?: unknown;
passwordCipher?: unknown;
@@ -9,6 +11,13 @@ type Sub2ApiAuthConfig = {
tokenExpiresAt?: unknown;
};
type Sub2ApiSubscriptionConfig = {
updatedAt?: unknown;
activeCount?: unknown;
totalUsedUsd?: unknown;
subscriptions?: unknown;
};
export type AccountCredentialMode = 'auto' | 'session' | 'apikey';
const VALID_CREDENTIAL_MODES = new Set<AccountCredentialMode>([
@@ -26,6 +35,7 @@ type AccountExtraConfig = {
};
autoRelogin?: AutoReloginConfig;
sub2apiAuth?: Sub2ApiAuthConfig;
sub2apiSubscription?: Sub2ApiSubscriptionConfig;
[key: string]: unknown;
};
@@ -64,6 +74,39 @@ function normalizeTimestampMs(raw: unknown): number | undefined {
return undefined;
}
function normalizeNonNegativeNumber(raw: unknown): number | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
return Math.round(raw * 1_000_000) / 1_000_000;
}
if (typeof raw === 'string') {
const parsed = Number(raw.trim());
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.round(parsed * 1_000_000) / 1_000_000;
}
}
return undefined;
}
function normalizeIsoDateTime(raw: unknown): string | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
const ms = raw > 10_000_000_000 ? raw : raw * 1000;
return new Date(ms).toISOString();
}
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
const numeric = Number(trimmed);
if (Number.isFinite(numeric) && numeric > 0) {
const ms = numeric > 10_000_000_000 ? numeric : numeric * 1000;
return new Date(ms).toISOString();
}
const parsed = Date.parse(trimmed);
if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
return undefined;
}
export function normalizeCredentialMode(raw: unknown): AccountCredentialMode | undefined {
if (typeof raw !== 'string') return undefined;
const normalized = raw.trim().toLowerCase();
@@ -134,6 +177,10 @@ export type ManagedSub2ApiAuth = {
tokenExpiresAt?: number;
};
export type StoredSub2ApiSubscriptionSummary = SubscriptionSummary & {
updatedAt: number;
};
export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): ManagedSub2ApiAuth | null {
const parsed = parseExtraConfig(extraConfig);
const raw = parsed.sub2apiAuth;
@@ -146,6 +193,103 @@ export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): Mana
: { refreshToken };
}
function normalizeSubscriptionItem(raw: unknown): SubscriptionPlanSummary | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const item = raw as Record<string, unknown>;
const normalized: SubscriptionPlanSummary = {};
const id = normalizeUserId(item.id);
if (id) normalized.id = id;
const groupId = normalizeUserId(item.groupId ?? item.group_id);
if (groupId) normalized.groupId = groupId;
const groupName = normalizeNonEmptyString(item.groupName ?? item.group_name);
if (groupName) normalized.groupName = groupName;
const status = normalizeNonEmptyString(item.status);
if (status) normalized.status = status;
const expiresAt = normalizeIsoDateTime(
item.expiresAt
?? item.expires_at
?? item.expiredAt
?? item.expired_at
?? item.endAt
?? item.end_at,
);
if (expiresAt) normalized.expiresAt = expiresAt;
const dailyUsedUsd = normalizeNonNegativeNumber(item.dailyUsedUsd ?? item.daily_used_usd);
if (dailyUsedUsd !== undefined) normalized.dailyUsedUsd = dailyUsedUsd;
const dailyLimitUsd = normalizeNonNegativeNumber(item.dailyLimitUsd ?? item.daily_limit_usd);
if (dailyLimitUsd !== undefined) normalized.dailyLimitUsd = dailyLimitUsd;
const weeklyUsedUsd = normalizeNonNegativeNumber(item.weeklyUsedUsd ?? item.weekly_used_usd);
if (weeklyUsedUsd !== undefined) normalized.weeklyUsedUsd = weeklyUsedUsd;
const weeklyLimitUsd = normalizeNonNegativeNumber(item.weeklyLimitUsd ?? item.weekly_limit_usd);
if (weeklyLimitUsd !== undefined) normalized.weeklyLimitUsd = weeklyLimitUsd;
const monthlyUsedUsd = normalizeNonNegativeNumber(item.monthlyUsedUsd ?? item.monthly_used_usd);
if (monthlyUsedUsd !== undefined) normalized.monthlyUsedUsd = monthlyUsedUsd;
const monthlyLimitUsd = normalizeNonNegativeNumber(item.monthlyLimitUsd ?? item.monthly_limit_usd);
if (monthlyLimitUsd !== undefined) normalized.monthlyLimitUsd = monthlyLimitUsd;
return Object.keys(normalized).length > 0 ? normalized : null;
}
function normalizeSubscriptionItems(raw: unknown): SubscriptionPlanSummary[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => normalizeSubscriptionItem(item))
.filter((item): item is SubscriptionPlanSummary => !!item);
}
export function normalizeSub2ApiSubscriptionSummary(
raw: unknown,
): StoredSub2ApiSubscriptionSummary | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const body = raw as Record<string, unknown>;
const subscriptions = normalizeSubscriptionItems(body.subscriptions);
const activeCount = normalizeNonNegativeNumber(body.activeCount ?? body.active_count);
const totalUsedUsd = normalizeNonNegativeNumber(body.totalUsedUsd ?? body.total_used_usd);
const updatedAt = normalizeTimestampMs(body.updatedAt ?? body.updated_at);
return {
activeCount: Math.trunc(activeCount ?? subscriptions.length),
totalUsedUsd: totalUsedUsd ?? 0,
subscriptions,
updatedAt: updatedAt ?? Date.now(),
};
}
export function buildStoredSub2ApiSubscriptionSummary(
summary: SubscriptionSummary,
updatedAt = Date.now(),
): StoredSub2ApiSubscriptionSummary {
return normalizeSub2ApiSubscriptionSummary({
...summary,
updatedAt,
}) || {
activeCount: Math.max(0, Math.trunc(summary.activeCount || 0)),
totalUsedUsd: normalizeNonNegativeNumber(summary.totalUsedUsd) ?? 0,
subscriptions: normalizeSubscriptionItems(summary.subscriptions),
updatedAt,
};
}
export function getSub2ApiSubscriptionFromExtraConfig(
extraConfig?: string | null,
): StoredSub2ApiSubscriptionSummary | null {
const parsed = parseExtraConfig(extraConfig);
return normalizeSub2ApiSubscriptionSummary(parsed.sub2apiSubscription);
}
export function guessPlatformUserIdFromUsername(username?: string | null): number | undefined {
const text = (username || '').trim();
if (!text) return undefined;
+250
View File
@@ -596,4 +596,254 @@ describe('backupService', () => {
expect(restoredAccount?.oauthAccountKey).toBe('oauth-user@example.com');
expect(restoredAccount?.oauthProjectId).toBe('oauth-project-id');
});
it('exports configured backup payload to webdav and records sync state', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValue(new Response(null, { status: 201 }));
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi-preferences.json',
username: 'alice',
password: 'secret-pass',
exportType: 'preferences',
autoSyncEnabled: false,
autoSyncCron: '0 * * * *',
}),
}).run();
await db.insert(schema.settings).values({
key: 'ui_locale',
value: JSON.stringify('zh-CN'),
}).run();
const result = await (backupService as any).exportBackupToWebdav();
expect(result).toMatchObject({
success: true,
fileUrl: 'https://dav.example.com/backups/metapi-preferences.json',
exportType: 'preferences',
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [targetUrl, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(targetUrl).toBe('https://dav.example.com/backups/metapi-preferences.json');
expect(init.method).toBe('PUT');
expect(init.headers).toMatchObject({
Authorization: `Basic ${Buffer.from('alice:secret-pass').toString('base64')}`,
'Content-Type': 'application/json',
});
const payload = JSON.parse(String(init.body));
expect(payload.type).toBe('preferences');
expect(payload.preferences.settings).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: 'ui_locale', value: 'zh-CN' }),
]),
);
const syncState = await db.select().from(schema.settings).where(eq(schema.settings.key, 'backup_webdav_state_v1')).get();
expect(syncState?.value).toContain('"lastSyncAt"');
expect(syncState?.value).toContain('"lastError":null');
fetchSpy.mockRestore();
});
it('imports backup payload from webdav into local data', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const remotePayload = {
version: '2.0',
timestamp: Date.now(),
accounts: {
sites: [
{
id: 1,
name: 'remote-site',
url: 'https://remote.example.com',
platform: 'new-api',
externalCheckinUrl: null,
proxyUrl: null,
useSystemProxy: false,
customHeaders: null,
status: 'active',
isPinned: false,
sortOrder: 0,
globalWeight: 1,
apiKey: null,
createdAt: '2026-03-20T00:00:00.000Z',
updatedAt: '2026-03-20T00:00:00.000Z',
},
],
accounts: [
{
id: 1,
siteId: 1,
username: 'remote-user',
accessToken: 'remote-session',
apiToken: null,
oauthProvider: null,
oauthAccountKey: null,
oauthProjectId: null,
balance: 0,
balanceUsed: 0,
quota: 0,
unitCost: null,
valueScore: 0,
status: 'active',
isPinned: false,
sortOrder: 0,
checkinEnabled: false,
lastCheckinAt: null,
lastBalanceRefresh: null,
extraConfig: null,
createdAt: '2026-03-20T00:00:00.000Z',
updatedAt: '2026-03-20T00:00:00.000Z',
},
],
accountTokens: [],
tokenRoutes: [],
routeChannels: [],
routeGroupSources: [],
},
};
fetchSpy.mockResolvedValue(new Response(JSON.stringify(remotePayload), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi-all.json',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 * * * *',
}),
}).run();
const result = await (backupService as any).importBackupFromWebdav();
expect(result.success).toBe(true);
expect(result.sections.accounts).toBe(true);
const sites = await db.select().from(schema.sites).all();
const accounts = await db.select().from(schema.accounts).all();
expect(sites).toHaveLength(1);
expect(accounts).toHaveLength(1);
expect(accounts[0].username).toBe('remote-user');
expect(fetchSpy).toHaveBeenCalledWith(
'https://dav.example.com/backups/metapi-all.json',
expect.objectContaining({
method: 'GET',
}),
);
fetchSpy.mockRestore();
});
it('times out stalled webdav export requests', async () => {
vi.useFakeTimers();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockImplementation((_, init) => {
const signal = init?.signal as AbortSignal | undefined;
if (!signal) {
return Promise.reject(new Error('missing abort signal')) as Promise<Response>;
}
return new Promise<Response>((_, reject) => {
signal.addEventListener('abort', () => {
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
}, { once: true });
});
});
try {
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 */6 * * *',
}),
}).run();
const exportAssertion = expect(backupService.exportBackupToWebdav()).rejects.toThrow('WebDAV 请求超时');
await vi.advanceTimersByTimeAsync(16_000);
await exportAssertion;
} finally {
fetchSpy.mockRestore();
vi.useRealTimers();
}
});
it('times out stalled webdav import requests', async () => {
vi.useFakeTimers();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockImplementation((_, init) => {
const signal = init?.signal as AbortSignal | undefined;
if (!signal) {
return Promise.reject(new Error('missing abort signal')) as Promise<Response>;
}
return new Promise<Response>((_, reject) => {
signal.addEventListener('abort', () => {
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
}, { once: true });
});
});
try {
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 */6 * * *',
}),
}).run();
const importAssertion = expect(backupService.importBackupFromWebdav()).rejects.toThrow('WebDAV 请求超时');
await vi.advanceTimersByTimeAsync(16_000);
await importAssertion;
} finally {
fetchSpy.mockRestore();
vi.useRealTimers();
}
});
it('does not schedule malformed imported webdav config', async () => {
const cronModule = await import('node-cron');
const scheduleSpy = vi.spyOn(cronModule.default, 'schedule');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
try {
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'not-a-valid-url',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
}),
}).run();
await expect(backupService.reloadBackupWebdavScheduler()).resolves.toBeUndefined();
expect(scheduleSpy).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid config'));
} finally {
backupService.__resetBackupWebdavSchedulerForTests();
scheduleSpy.mockRestore();
warnSpy.mockRestore();
}
});
});
+323 -1
View File
@@ -1,4 +1,4 @@
import { asc } from 'drizzle-orm';
import { asc, eq } from 'drizzle-orm';
import cron from 'node-cron';
import { db, schema } from '../db/index.js';
import { upsertSetting } from '../db/upsertSetting.js';
@@ -9,6 +9,32 @@ const BACKUP_VERSION = '2.0';
export type BackupExportType = 'all' | 'accounts' | 'preferences';
export interface BackupWebdavConfig {
enabled: boolean;
fileUrl: string;
username: string;
password: string;
exportType: BackupExportType;
autoSyncEnabled: boolean;
autoSyncCron: string;
}
export interface BackupWebdavConfigView {
enabled: boolean;
fileUrl: string;
username: string;
exportType: BackupExportType;
autoSyncEnabled: boolean;
autoSyncCron: string;
hasPassword: boolean;
passwordMasked: string;
}
export interface BackupWebdavState {
lastSyncAt: string | null;
lastError: string | null;
}
type SiteRow = typeof schema.sites.$inferSelect;
type AccountRow = typeof schema.accounts.$inferSelect;
type AccountTokenRow = typeof schema.accountTokens.$inferSelect;
@@ -76,6 +102,11 @@ const EXCLUDED_SETTING_KEYS = new Set<string>([
// Keep current admin login credential unchanged to avoid accidental lock-out.
'auth_token',
]);
const BACKUP_WEBDAV_CONFIG_SETTING_KEY = 'backup_webdav_config_v1';
const BACKUP_WEBDAV_STATE_SETTING_KEY = 'backup_webdav_state_v1';
const BACKUP_WEBDAV_DEFAULT_AUTO_SYNC_CRON = '0 */6 * * *';
const BACKUP_WEBDAV_FETCH_TIMEOUT_MS = 15_000;
let backupWebdavTask: cron.ScheduledTask | null = null;
const DIRECT_API_PLATFORMS = new Set([
'openai',
@@ -722,6 +753,131 @@ function stringifySettingValue(value: unknown): string {
return JSON.stringify(value);
}
function isValidBackupExportType(value: unknown): value is BackupExportType {
return value === 'all' || value === 'accounts' || value === 'preferences';
}
function maskSecret(value: string): string {
if (!value) return '';
if (value.length <= 4) return '****';
return `${value.slice(0, 2)}****${value.slice(-2)}`;
}
function isValidHttpUrl(raw: string): boolean {
if (!raw.trim()) return false;
try {
const parsed = new URL(raw);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
function normalizeBackupWebdavConfig(raw: unknown): BackupWebdavConfig {
const source = isRecord(raw) ? raw : {};
const exportType = isValidBackupExportType(source.exportType) ? source.exportType : 'all';
const autoSyncCron = typeof source.autoSyncCron === 'string' && cron.validate(source.autoSyncCron)
? source.autoSyncCron
: BACKUP_WEBDAV_DEFAULT_AUTO_SYNC_CRON;
return {
enabled: source.enabled === true,
fileUrl: asString(source.fileUrl),
username: asString(source.username),
password: typeof source.password === 'string' ? source.password : '',
exportType,
autoSyncEnabled: source.autoSyncEnabled === true,
autoSyncCron,
};
}
function normalizeBackupWebdavState(raw: unknown): BackupWebdavState {
const source = isRecord(raw) ? raw : {};
return {
lastSyncAt: typeof source.lastSyncAt === 'string' && source.lastSyncAt.trim() ? source.lastSyncAt : null,
lastError: typeof source.lastError === 'string' && source.lastError.trim() ? source.lastError : null,
};
}
function toBackupWebdavConfigView(config: BackupWebdavConfig): BackupWebdavConfigView {
return {
enabled: config.enabled,
fileUrl: config.fileUrl,
username: config.username,
exportType: config.exportType,
autoSyncEnabled: config.autoSyncEnabled,
autoSyncCron: config.autoSyncCron,
hasPassword: config.password.length > 0,
passwordMasked: maskSecret(config.password),
};
}
async function readSettingValue(key: string): Promise<unknown> {
const row = await db.select({ value: schema.settings.value }).from(schema.settings).where(eq(schema.settings.key, key)).get();
return parseSettingValue(row?.value ?? null);
}
async function loadBackupWebdavConfig(): Promise<BackupWebdavConfig> {
return normalizeBackupWebdavConfig(await readSettingValue(BACKUP_WEBDAV_CONFIG_SETTING_KEY));
}
async function loadBackupWebdavState(): Promise<BackupWebdavState> {
return normalizeBackupWebdavState(await readSettingValue(BACKUP_WEBDAV_STATE_SETTING_KEY));
}
async function writeBackupWebdavState(next: BackupWebdavState) {
await upsertSetting(BACKUP_WEBDAV_STATE_SETTING_KEY, next);
}
function resolveBackupWebdavAuthHeader(config: BackupWebdavConfig): string | null {
if (!config.username && !config.password) return null;
return `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`;
}
function validateBackupWebdavConfig(config: BackupWebdavConfig) {
if (config.enabled && !isValidHttpUrl(config.fileUrl)) {
throw new Error('WebDAV 文件地址无效,请填写 http/https 文件 URL');
}
if (!isValidBackupExportType(config.exportType)) {
throw new Error('WebDAV 导出类型无效,仅支持 all/accounts/preferences');
}
if (!cron.validate(config.autoSyncCron)) {
throw new Error('WebDAV 自动同步 Cron 表达式无效');
}
if (config.autoSyncEnabled && !config.enabled) {
throw new Error('启用自动同步前请先启用 WebDAV 备份');
}
}
async function fetchBackupWebdav(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController();
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
controller.abort();
}, BACKUP_WEBDAV_FETCH_TIMEOUT_MS);
try {
return await fetch(url, {
...init,
signal: controller.signal,
});
} catch (error: any) {
if (error?.name === 'AbortError') {
throw new Error(`WebDAV 请求超时(${Math.max(1, Math.round(BACKUP_WEBDAV_FETCH_TIMEOUT_MS / 1000))}s)`);
}
throw error;
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
}
}
function stopBackupWebdavScheduler() {
backupWebdavTask?.stop();
backupWebdavTask = null;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
@@ -1103,3 +1259,169 @@ export async function importBackup(data: RawBackupData): Promise<BackupImportRes
warnings: importMetadata.warnings,
};
}
export async function getBackupWebdavConfig() {
const [config, state] = await Promise.all([
loadBackupWebdavConfig(),
loadBackupWebdavState(),
]);
return {
success: true,
config: toBackupWebdavConfigView(config),
state,
};
}
export async function saveBackupWebdavConfig(input: Partial<BackupWebdavConfig> & { password?: string; clearPassword?: boolean }) {
const existing = await loadBackupWebdavConfig();
const next: BackupWebdavConfig = {
enabled: input.enabled !== undefined ? input.enabled === true : existing.enabled,
fileUrl: input.fileUrl !== undefined ? asString(input.fileUrl) : existing.fileUrl,
username: input.username !== undefined ? asString(input.username) : existing.username,
password: input.clearPassword
? ''
: (input.password !== undefined
? String(input.password)
: existing.password),
exportType: isValidBackupExportType(input.exportType) ? input.exportType : existing.exportType,
autoSyncEnabled: input.autoSyncEnabled !== undefined ? input.autoSyncEnabled === true : existing.autoSyncEnabled,
autoSyncCron: typeof input.autoSyncCron === 'string' && input.autoSyncCron.trim()
? input.autoSyncCron.trim()
: existing.autoSyncCron,
};
if (!next.enabled) {
next.autoSyncEnabled = false;
}
validateBackupWebdavConfig(next);
await upsertSetting(BACKUP_WEBDAV_CONFIG_SETTING_KEY, next);
await reloadBackupWebdavScheduler();
return getBackupWebdavConfig();
}
export async function exportBackupToWebdav(type?: BackupExportType) {
const config = await loadBackupWebdavConfig();
validateBackupWebdavConfig(config);
if (!config.enabled) {
throw new Error('WebDAV 备份未启用');
}
if (!config.fileUrl) {
throw new Error('WebDAV 文件地址不能为空');
}
const exportType = type && isValidBackupExportType(type) ? type : config.exportType;
const payload = await exportBackup(exportType);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const authHeader = resolveBackupWebdavAuthHeader(config);
if (authHeader) headers.Authorization = authHeader;
try {
const response = await fetchBackupWebdav(config.fileUrl, {
method: 'PUT',
headers,
body: JSON.stringify(payload, null, 2),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`WebDAV 导出失败:HTTP ${response.status}${text ? ` ${text.slice(0, 120)}` : ''}`);
}
const syncedAt = new Date().toISOString();
await writeBackupWebdavState({
lastSyncAt: syncedAt,
lastError: null,
});
return {
success: true,
fileUrl: config.fileUrl,
exportType,
syncedAt,
lastSyncAt: syncedAt,
lastError: null,
};
} catch (error: any) {
const previousState = await loadBackupWebdavState();
await writeBackupWebdavState({
lastSyncAt: previousState.lastSyncAt,
lastError: error?.message || 'WebDAV 导出失败',
});
throw error;
}
}
export async function importBackupFromWebdav() {
const config = await loadBackupWebdavConfig();
validateBackupWebdavConfig(config);
if (!config.enabled) {
throw new Error('WebDAV 备份未启用');
}
if (!config.fileUrl) {
throw new Error('WebDAV 文件地址不能为空');
}
const headers: Record<string, string> = {};
const authHeader = resolveBackupWebdavAuthHeader(config);
if (authHeader) headers.Authorization = authHeader;
try {
const response = await fetchBackupWebdav(config.fileUrl, {
method: 'GET',
headers,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`WebDAV 导入失败:HTTP ${response.status}${text ? ` ${text.slice(0, 120)}` : ''}`);
}
const raw = await response.text();
const parsed = JSON.parse(raw) as RawBackupData;
const result = await importBackup(parsed);
const syncedAt = new Date().toISOString();
await writeBackupWebdavState({
lastSyncAt: syncedAt,
lastError: null,
});
return {
success: true,
fileUrl: config.fileUrl,
syncedAt,
lastSyncAt: syncedAt,
lastError: null,
...result,
};
} catch (error: any) {
const previousState = await loadBackupWebdavState();
await writeBackupWebdavState({
lastSyncAt: previousState.lastSyncAt,
lastError: error?.message || 'WebDAV 导入失败',
});
throw error;
}
}
export async function reloadBackupWebdavScheduler() {
stopBackupWebdavScheduler();
const config = await loadBackupWebdavConfig();
if (!config.enabled || !config.autoSyncEnabled) return;
try {
validateBackupWebdavConfig(config);
} catch (error: any) {
console.warn(`[backup/webdav] invalid config: ${error?.message || 'unknown error'}`);
return;
}
backupWebdavTask = cron.schedule(config.autoSyncCron, () => {
void exportBackupToWebdav(config.exportType).catch((error) => {
console.warn(`[backup/webdav] auto sync failed: ${(error as Error)?.message || 'unknown error'}`);
});
});
}
export function __resetBackupWebdavSchedulerForTests() {
stopBackupWebdavScheduler();
}
+6
View File
@@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm';
import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.js';
import { reportTokenExpired } from './alertService.js';
import {
buildStoredSub2ApiSubscriptionSummary,
getAutoReloginConfig,
getCredentialModeFromExtraConfig,
getProxyUrlFromExtraConfig,
@@ -483,6 +484,11 @@ export async function refreshBalance(accountId: number) {
if (typeof balanceInfo.todayIncome === 'number' && Number.isFinite(balanceInfo.todayIncome)) {
nextExtraConfig = updateTodayIncomeSnapshot(nextExtraConfig, balanceInfo.todayIncome);
}
if (balanceInfo.subscriptionSummary && isSub2ApiPlatform(site.platform)) {
nextExtraConfig = mergeAccountExtraConfig(nextExtraConfig, {
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary(balanceInfo.subscriptionSummary),
});
}
const existingRuntimeHealth = extractRuntimeHealth(nextExtraConfig);
const keepUnsupportedCheckinDegraded = isUnsupportedCheckinRuntimeHealth(existingRuntimeHealth);
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const cronStopMock = vi.fn();
const scheduleMock = vi.fn(() => ({
stop: cronStopMock,
}));
const validateMock = vi.fn(() => true);
const allMock = vi.fn();
vi.mock('node-cron', () => ({
default: {
schedule: (...args: unknown[]) => scheduleMock(...args),
validate: (...args: unknown[]) => validateMock(...args),
},
}));
vi.mock('../db/index.js', () => {
const queryChain = {
where: () => queryChain,
get: () => undefined,
all: () => [],
from: () => queryChain,
innerJoin: () => queryChain,
};
return {
db: {
select: () => queryChain,
},
schema: {
settings: { key: 'key' },
accounts: { checkinEnabled: 'checkinEnabled', status: 'status' },
sites: { id: 'id' },
},
};
});
vi.mock('./checkinService.js', () => ({
checkinAll: (...args: unknown[]) => allMock(...args),
}));
describe('checkinScheduler', () => {
beforeEach(() => {
vi.useFakeTimers();
cronStopMock.mockReset();
scheduleMock.mockClear();
validateMock.mockClear();
allMock.mockReset();
});
afterEach(async () => {
const scheduler = await import('./checkinScheduler.js');
scheduler.__resetCheckinSchedulerForTests();
vi.useRealTimers();
});
it('switches from cron mode to interval mode and back', async () => {
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval');
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');
const scheduler = await import('./checkinScheduler.js');
scheduler.updateCheckinSchedule({
mode: 'cron',
cronExpr: '0 8 * * *',
intervalHours: 6,
});
expect(scheduleMock).toHaveBeenCalledTimes(1);
scheduler.updateCheckinSchedule({
mode: 'interval',
intervalHours: 6,
});
expect(cronStopMock).toHaveBeenCalledTimes(1);
expect(setIntervalSpy).toHaveBeenCalledTimes(1);
scheduler.updateCheckinSchedule({
mode: 'cron',
cronExpr: '5 9 * * *',
intervalHours: 6,
});
expect(clearIntervalSpy).toHaveBeenCalledTimes(1);
expect(scheduleMock).toHaveBeenCalledTimes(2);
});
it('selects due accounts from the last successful checkin time', async () => {
const scheduler = await import('./checkinScheduler.js');
const now = new Date('2026-03-20T12:00:00.000Z');
expect(scheduler.selectDueIntervalCheckinAccountIds([
{ id: 1, lastCheckinAt: null },
{ id: 2, lastCheckinAt: '2026-03-20T05:59:59.000Z' },
{ id: 3, lastCheckinAt: '2026-03-20T06:30:00.000Z' },
], 6, now)).toEqual([1, 2]);
});
});
+148 -8
View File
@@ -9,13 +9,18 @@ import { sendNotification } from './notifyService.js';
import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js';
import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js';
export type CheckinScheduleMode = 'cron' | 'interval';
let checkinTask: cron.ScheduledTask | null = null;
let checkinIntervalTimer: ReturnType<typeof setInterval> | null = null;
let balanceTask: cron.ScheduledTask | null = null;
let dailySummaryTask: cron.ScheduledTask | null = null;
let logCleanupTask: cron.ScheduledTask | null = null;
const intervalAttemptByAccount = new Map<number, number>();
const DAILY_SUMMARY_DEFAULT_CRON = '58 23 * * *';
const LOG_CLEANUP_DEFAULT_CRON = '0 6 * * *';
const CHECKIN_INTERVAL_POLL_MS = 60_000;
async function resolveJsonSetting<T>(
settingKey: string,
@@ -54,7 +59,7 @@ function createCheckinTask(cronExpr: string) {
return cron.schedule(cronExpr, async () => {
console.log(`[Scheduler] Running check-in at ${new Date().toISOString()}`);
try {
const results = await checkinAll();
const results = await checkinAll({ scheduleMode: 'cron' });
const success = results.filter((r) => r.result.success).length;
const failed = results.length - success;
console.log(`[Scheduler] Check-in complete: ${success} success, ${failed} failed`);
@@ -64,6 +69,94 @@ function createCheckinTask(cronExpr: string) {
});
}
type IntervalCheckinCandidate = {
id: number;
lastCheckinAt?: string | null;
};
export function selectDueIntervalCheckinAccountIds(
rows: IntervalCheckinCandidate[],
intervalHours: number,
now = new Date(),
attemptState = intervalAttemptByAccount,
) {
const nowMs = now.getTime();
const intervalMs = Math.max(1, intervalHours) * 60 * 60 * 1000;
return rows
.filter((row) => {
const lastCheckinMs = row.lastCheckinAt ? Date.parse(row.lastCheckinAt) : Number.NaN;
const lastAttemptMs = attemptState.get(row.id);
if (Number.isFinite(lastCheckinMs)) {
if (nowMs - lastCheckinMs < intervalMs) return false;
if (typeof lastAttemptMs === 'number' && lastAttemptMs >= lastCheckinMs && nowMs - lastAttemptMs < intervalMs) {
return false;
}
return true;
}
if (typeof lastAttemptMs === 'number' && nowMs - lastAttemptMs < intervalMs) return false;
return true;
})
.map((row) => row.id);
}
async function runIntervalCheckinPass(now = new Date()) {
const rows = await db
.select()
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.all();
const dueAccountIds = selectDueIntervalCheckinAccountIds(
rows
.filter((row: any) => row.accounts?.checkinEnabled === true && row.accounts?.status === 'active' && row.sites?.status !== 'disabled')
.map((row: any) => ({
id: row.accounts.id,
lastCheckinAt: row.accounts.lastCheckinAt,
})),
config.checkinIntervalHours,
now,
);
if (dueAccountIds.length === 0) return;
try {
const results = await checkinAll({
accountIds: dueAccountIds,
scheduleMode: 'interval',
});
const nowMs = now.getTime();
for (const item of results) {
intervalAttemptByAccount.set(item.accountId, nowMs);
}
const success = results.filter((r) => r.result.success).length;
const failed = results.length - success;
console.log(`[Scheduler] Interval check-in complete: ${success} success, ${failed} failed`);
} catch (err) {
console.error('[Scheduler] Interval check-in error:', err);
}
}
function stopCheckinSchedule() {
checkinTask?.stop();
checkinTask = null;
if (checkinIntervalTimer) {
clearInterval(checkinIntervalTimer);
checkinIntervalTimer = null;
}
}
function startCheckinSchedule() {
stopCheckinSchedule();
if (config.checkinScheduleMode === 'interval') {
checkinIntervalTimer = setInterval(() => {
void runIntervalCheckinPass();
}, CHECKIN_INTERVAL_POLL_MS);
return;
}
checkinTask = createCheckinTask(config.checkinCron);
}
function createBalanceTask(cronExpr: string) {
return cron.schedule(cronExpr, async () => {
console.log(`[Scheduler] Refreshing balances at ${new Date().toISOString()}`);
@@ -119,6 +212,15 @@ function createLogCleanupTask(cronExpr: string) {
export async function startScheduler() {
const activeCheckinCron = await resolveCronSetting('checkin_cron', config.checkinCron);
const activeCheckinScheduleMode = await resolveJsonSetting<CheckinScheduleMode>(
'checkin_schedule_mode',
(value): value is CheckinScheduleMode => value === 'cron' || value === 'interval',
config.checkinScheduleMode as CheckinScheduleMode,
);
const activeCheckinIntervalHours = await resolvePositiveIntegerSetting(
'checkin_interval_hours',
config.checkinIntervalHours,
);
const activeBalanceCron = await resolveCronSetting('balance_refresh_cron', config.balanceRefreshCron);
const activeDailySummaryCron = await resolveCronSetting('daily_summary_cron', DAILY_SUMMARY_DEFAULT_CRON);
const activeLogCleanupCron = await resolveCronSetting('log_cleanup_cron', config.logCleanupCron || LOG_CLEANUP_DEFAULT_CRON);
@@ -135,22 +237,24 @@ export async function startScheduler() {
normalizeLogCleanupRetentionDays(config.logCleanupRetentionDays),
);
config.checkinCron = activeCheckinCron;
config.checkinScheduleMode = activeCheckinScheduleMode;
config.checkinIntervalHours = Math.min(24, Math.max(1, activeCheckinIntervalHours));
config.balanceRefreshCron = activeBalanceCron;
config.logCleanupCron = activeLogCleanupCron;
config.logCleanupUsageLogsEnabled = activeLogCleanupUsageLogsEnabled;
config.logCleanupProgramLogsEnabled = activeLogCleanupProgramLogsEnabled;
config.logCleanupRetentionDays = activeLogCleanupRetentionDays;
checkinTask?.stop();
stopCheckinSchedule();
balanceTask?.stop();
dailySummaryTask?.stop();
logCleanupTask?.stop();
checkinTask = createCheckinTask(activeCheckinCron);
startCheckinSchedule();
balanceTask = createBalanceTask(activeBalanceCron);
dailySummaryTask = createDailySummaryTask(activeDailySummaryCron);
logCleanupTask = createLogCleanupTask(activeLogCleanupCron);
console.log(`[Scheduler] Check-in cron: ${activeCheckinCron}`);
console.log(`[Scheduler] Check-in schedule: ${config.checkinScheduleMode} (${config.checkinScheduleMode === 'cron' ? activeCheckinCron : `${config.checkinIntervalHours}h`})`);
console.log(`[Scheduler] Balance refresh cron: ${activeBalanceCron}`);
console.log(`[Scheduler] Daily summary cron: ${activeDailySummaryCron}`);
console.log(
@@ -159,10 +263,35 @@ export async function startScheduler() {
}
export function updateCheckinCron(cronExpr: string) {
if (!cron.validate(cronExpr)) throw new Error(`Invalid cron: ${cronExpr}`);
config.checkinCron = cronExpr;
checkinTask?.stop();
checkinTask = createCheckinTask(cronExpr);
updateCheckinSchedule({
mode: 'cron',
cronExpr,
intervalHours: config.checkinIntervalHours,
});
}
export function updateCheckinSchedule(input: {
mode: CheckinScheduleMode;
cronExpr?: string;
intervalHours?: number;
}) {
const nextMode = input.mode;
if (nextMode !== 'cron' && nextMode !== 'interval') {
throw new Error(`Invalid checkin schedule mode: ${String(nextMode)}`);
}
const nextCronExpr = input.cronExpr ?? config.checkinCron;
if (!cron.validate(nextCronExpr)) throw new Error(`Invalid cron: ${nextCronExpr}`);
const nextIntervalHours = input.intervalHours ?? config.checkinIntervalHours;
if (!Number.isFinite(nextIntervalHours) || nextIntervalHours < 1 || nextIntervalHours > 24) {
throw new Error(`Invalid interval hours: ${String(nextIntervalHours)}`);
}
config.checkinScheduleMode = nextMode;
config.checkinCron = nextCronExpr;
config.checkinIntervalHours = Math.trunc(nextIntervalHours);
startCheckinSchedule();
}
export function updateBalanceRefreshCron(cronExpr: string) {
@@ -191,3 +320,14 @@ export function updateLogCleanupSettings(input: {
logCleanupTask?.stop();
logCleanupTask = createLogCleanupTask(cronExpr);
}
export function __resetCheckinSchedulerForTests() {
stopCheckinSchedule();
balanceTask?.stop();
dailySummaryTask?.stop();
logCleanupTask?.stop();
balanceTask = null;
dailySummaryTask = null;
logCleanupTask = null;
intervalAttemptByAccount.clear();
}
@@ -248,6 +248,63 @@ describe('checkinService auto relogin', () => {
expect(notifyMock).not.toHaveBeenCalled();
});
it('does not advance lastCheckinAt for already checked in responses in interval mode', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 16,
username: 'interval-user',
accessToken: 'token',
status: 'active',
extraConfig: null,
},
sites: {
id: 16,
name: 'demo',
url: 'https://example.com',
platform: 'new-api',
},
},
]);
adapterMock.checkin.mockResolvedValue({ success: false, message: '今天已经签到过啦' });
const { checkinAccount } = await import('./checkinService.js');
const result = await checkinAccount(16, { scheduleMode: 'interval' });
expect(result.success).toBe(true);
expect(result.status).toBe('success');
expect(updateSetMock).not.toHaveBeenCalledWith(expect.objectContaining({ lastCheckinAt: expect.any(String) }));
});
it('advances lastCheckinAt when interval mode gets a direct success', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 17,
username: 'interval-success',
accessToken: 'token',
status: 'active',
extraConfig: null,
},
sites: {
id: 17,
name: 'demo',
url: 'https://example.com',
platform: 'new-api',
},
},
]);
adapterMock.checkin.mockResolvedValue({ success: true, message: '签到成功' });
const { checkinAccount } = await import('./checkinService.js');
const result = await checkinAccount(17, { scheduleMode: 'interval' });
expect(result.success).toBe(true);
expect(updateSetMock).toHaveBeenCalledWith(expect.objectContaining({ lastCheckinAt: expect.any(String) }));
});
it('treats unsupported checkin endpoint responses as skipped', async () => {
selectAllMock.mockReturnValue([
{
@@ -283,6 +340,40 @@ describe('checkinService auto relogin', () => {
expect(notifyMock).not.toHaveBeenCalled();
});
it('skips account updates when unsupported checkin responses do not change account state', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 18,
username: 'plain-user',
accessToken: 'token',
status: 'active',
extraConfig: null,
},
sites: {
id: 18,
name: 'done-hub',
url: 'https://done.example.com',
platform: 'donehub',
},
},
]);
adapterMock.checkin.mockResolvedValue({
success: false,
message: 'checkin endpoint not found',
});
const { checkinAccount } = await import('./checkinService.js');
const result = await checkinAccount(18);
expect(result.success).toBe(true);
expect(result.status).toBe('skipped');
expect(updateSetMock).not.toHaveBeenCalled();
const firstInsertPayload = insertValuesMock.mock.calls[0]?.[0] as Record<string, unknown>;
expect(firstInsertPayload?.status).toBe('skipped');
});
it('treats sub2api checkin unsupported message as skipped', async () => {
selectAllMock.mockReturnValue([
{
+19 -10
View File
@@ -120,7 +120,7 @@ async function tryAutoRelogin(account: any, site: any): Promise<string | null> {
return result.accessToken;
}
export async function checkinAccount(accountId: number, options?: { skipEvent?: boolean }) {
export async function checkinAccount(accountId: number, options?: { skipEvent?: boolean; scheduleMode?: 'cron' | 'interval' }) {
const rows = await db
.select()
.from(schema.accounts)
@@ -200,6 +200,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
const effectiveSuccess = result.success || alreadyCheckedIn || unsupportedCheckin || manualVerificationRequired;
const shouldRefreshBalance = result.success || alreadyCheckedIn;
const directCheckinSuccess = result.success && !alreadyCheckedIn && !unsupportedCheckin;
const shouldAdvanceLastCheckinAt = directCheckinSuccess || (alreadyCheckedIn && options?.scheduleMode !== 'interval');
const normalizedStatus: CheckinExecutionStatus = effectiveSuccess
? ((unsupportedCheckin || manualVerificationRequired) ? 'skipped' : 'success')
: 'failed';
@@ -219,9 +220,10 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
source: 'checkin',
});
const updates: Record<string, unknown> = {
lastCheckinAt: new Date().toISOString(),
};
const updates: Record<string, unknown> = {};
if (shouldAdvanceLastCheckinAt) {
updates.lastCheckinAt = new Date().toISOString();
}
if (!storedPlatformUserId && guessedPlatformUserId) {
updates.extraConfig = mergeAccountExtraConfig(account.extraConfig, {
platformUserId: guessedPlatformUserId,
@@ -232,10 +234,12 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
updates.updatedAt = new Date().toISOString();
}
await db.update(schema.accounts)
.set(updates)
.where(eq(schema.accounts.id, accountId))
.run();
if (Object.keys(updates).length > 0) {
await db.update(schema.accounts)
.set(updates)
.where(eq(schema.accounts.id, accountId))
.run();
}
if (shouldRefreshBalance) {
try {
@@ -316,7 +320,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
};
}
export async function checkinAll() {
export async function checkinAll(options?: { accountIds?: number[]; scheduleMode?: 'cron' | 'interval' }) {
const rows = await db
.select()
.from(schema.accounts)
@@ -329,10 +333,12 @@ export async function checkinAll() {
)
.all();
const scopedAccountIds = options?.accountIds ? new Set(options.accountIds) : null;
const results: Array<{ accountId: number; username: string | null; site: string; result: any }> = [];
const grouped = new Map<number, typeof rows>();
for (const row of rows) {
if (scopedAccountIds && !scopedAccountIds.has(row.accounts.id)) continue;
const siteId = row.sites.id;
if (!grouped.has(siteId)) grouped.set(siteId, []);
grouped.get(siteId)!.push(row);
@@ -340,7 +346,10 @@ export async function checkinAll() {
const promises = Array.from(grouped.entries()).map(async ([_, siteRows]) => {
for (const row of siteRows) {
const r = await checkinAccount(row.accounts.id, { skipEvent: true });
const r = await checkinAccount(row.accounts.id, {
skipEvent: true,
scheduleMode: options?.scheduleMode,
});
results.push({
accountId: row.accounts.id,
username: row.accounts.username,
@@ -1,5 +1,5 @@
import currentContract from '../db/generated/schemaContract.json' with { type: 'json' };
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import {
__databaseMigrationServiceTestUtils,
maskConnectionString,
@@ -10,6 +10,42 @@ function cloneContract<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function createDbSchemaMock() {
return {
settings: { __table: 'settings' },
sites: { __table: 'sites' },
siteAnnouncements: { __table: 'siteAnnouncements' },
siteDisabledModels: { __table: 'siteDisabledModels' },
accounts: { __table: 'accounts' },
accountTokens: { __table: 'accountTokens' },
checkinLogs: { __table: 'checkinLogs' },
modelAvailability: { __table: 'modelAvailability' },
tokenModelAvailability: { __table: 'tokenModelAvailability' },
tokenRoutes: { __table: 'tokenRoutes' },
routeChannels: { __table: 'routeChannels' },
routeGroupSources: { __table: 'routeGroupSources' },
proxyLogs: { __table: 'proxyLogs' },
proxyVideoTasks: { __table: 'proxyVideoTasks' },
proxyFiles: { __table: 'proxyFiles' },
downstreamApiKeys: { __table: 'downstreamApiKeys' },
events: { __table: 'events' },
};
}
function createDbMock(rowsByTable: Record<string, unknown[]>) {
return {
select() {
return {
from(table: { __table: string }) {
return {
all: async () => rowsByTable[table.__table] ?? [],
};
},
};
},
};
}
describe('databaseMigrationService', () => {
it('accepts postgres migration input with normalized url', () => {
const normalized = normalizeMigrationInput({
@@ -179,6 +215,7 @@ describe('databaseMigrationService', () => {
customHeaders: '{"x-site-scope":"internal"}',
status: 'active',
}],
siteAnnouncements: [],
siteDisabledModels: [],
accounts: [],
accountTokens: [],
@@ -214,6 +251,7 @@ describe('databaseMigrationService', () => {
timestamp: Date.now(),
accounts: {
sites: [],
siteAnnouncements: [],
siteDisabledModels: [{
id: 3,
siteId: 12,
@@ -295,4 +333,136 @@ describe('databaseMigrationService', () => {
expect(routeModeIndex).toBeGreaterThanOrEqual(0);
expect(tokenRouteStatement?.values[routeModeIndex]).toBe('explicit_group');
});
it('includes site announcements in migration statements', () => {
const statements = __databaseMigrationServiceTestUtils.buildStatements({
version: 'test',
timestamp: Date.now(),
accounts: {
sites: [],
siteDisabledModels: [],
accounts: [],
accountTokens: [],
checkinLogs: [],
modelAvailability: [],
tokenModelAvailability: [],
tokenRoutes: [],
routeChannels: [],
routeGroupSources: [],
proxyLogs: [],
proxyVideoTasks: [],
proxyFiles: [],
downstreamApiKeys: [],
events: [],
siteAnnouncements: [{
id: 11,
siteId: 3,
platform: 'openai',
sourceKey: 'notice-1',
title: '????',
content: '????',
level: 'warning',
sourceUrl: 'https://example.com/notice',
startsAt: '2026-03-20T00:00:00.000Z',
endsAt: '2026-03-21T00:00:00.000Z',
upstreamCreatedAt: '2026-03-19T00:00:00.000Z',
upstreamUpdatedAt: '2026-03-20T00:00:00.000Z',
firstSeenAt: '2026-03-20T00:00:00.000Z',
lastSeenAt: '2026-03-20T01:00:00.000Z',
readAt: null,
dismissedAt: null,
rawPayload: '{"id":"notice-1"}',
}],
},
preferences: {
settings: [],
},
} as any);
const statement = statements.find((item) => item.table === 'site_announcements');
expect(statement).toBeDefined();
expect(statement?.columns).toContain('source_key');
expect(statement?.values[statement?.columns.indexOf('title') ?? -1]).toBe('????');
});
it('includes site announcements in migration summary', async () => {
vi.resetModules();
const rowsByTable = {
settings: [],
sites: [],
siteAnnouncements: [{
id: 11,
siteId: 3,
platform: 'openai',
sourceKey: 'notice-1',
title: '????',
content: '????',
level: 'warning',
sourceUrl: 'https://example.com/notice',
startsAt: '2026-03-20T00:00:00.000Z',
endsAt: '2026-03-21T00:00:00.000Z',
upstreamCreatedAt: '2026-03-19T00:00:00.000Z',
upstreamUpdatedAt: '2026-03-20T00:00:00.000Z',
firstSeenAt: '2026-03-20T00:00:00.000Z',
lastSeenAt: '2026-03-20T01:00:00.000Z',
readAt: null,
dismissedAt: null,
rawPayload: '{"id":"notice-1"}',
}],
siteDisabledModels: [],
accounts: [],
accountTokens: [],
checkinLogs: [],
modelAvailability: [],
tokenModelAvailability: [],
tokenRoutes: [],
routeChannels: [],
routeGroupSources: [],
proxyLogs: [],
proxyVideoTasks: [],
proxyFiles: [],
downstreamApiKeys: [],
events: [],
};
const client = {
dialect: 'sqlite',
connectionString: ':memory:',
ssl: false,
begin: vi.fn(async () => {}),
commit: vi.fn(async () => {}),
rollback: vi.fn(async () => {}),
execute: vi.fn(async () => []),
queryScalar: vi.fn(async () => 0),
close: vi.fn(async () => {}),
};
vi.doMock('../db/index.js', () => ({
db: createDbMock(rowsByTable),
schema: createDbSchemaMock(),
}));
vi.doMock('../db/runtimeSchemaBootstrap.js', () => ({
createRuntimeSchemaClient: async () => client,
ensureRuntimeDatabaseSchema: async () => {},
}));
try {
const { migrateCurrentDatabase } = await import('./databaseMigrationService.js');
const summary = await migrateCurrentDatabase({
dialect: 'sqlite',
connectionString: ':memory:',
overwrite: true,
});
expect(summary.rows.siteAnnouncements).toBe(1);
expect(client.begin).toHaveBeenCalledTimes(1);
expect(client.commit).toHaveBeenCalledTimes(1);
expect(client.close).toHaveBeenCalledTimes(1);
} finally {
vi.doUnmock('../db/index.js');
vi.doUnmock('../db/runtimeSchemaBootstrap.js');
vi.resetModules();
}
});
});
@@ -28,6 +28,7 @@ type BackupSnapshot = {
timestamp: number;
accounts: {
sites: Array<Record<string, unknown>>;
siteAnnouncements: Array<Record<string, unknown>>;
siteDisabledModels: Array<Record<string, unknown>>;
accounts: Array<Record<string, unknown>>;
accountTokens: Array<Record<string, unknown>>;
@@ -56,6 +57,7 @@ export interface DatabaseMigrationSummary {
timestamp: number;
rows: {
sites: number;
siteAnnouncements: number;
siteDisabledModels: number;
accounts: number;
accountTokens: number;
@@ -212,6 +214,7 @@ async function toBackupSnapshot(): Promise<BackupSnapshot> {
timestamp: Date.now(),
accounts: {
sites: await db.select().from(schema.sites).all() as Array<Record<string, unknown>>,
siteAnnouncements: await db.select().from(schema.siteAnnouncements).all() as Array<Record<string, unknown>>,
siteDisabledModels: await db.select().from(schema.siteDisabledModels).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>>,
@@ -257,6 +260,7 @@ async function clearTargetData(client: SqlClient): Promise<void> {
'proxy_files',
'account_tokens',
'accounts',
'site_announcements',
'site_disabled_models',
'token_routes',
'sites',
@@ -309,6 +313,50 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
});
}
for (const row of snapshot.accounts.siteAnnouncements || []) {
statements.push({
table: 'site_announcements',
columns: [
'id',
'site_id',
'platform',
'source_key',
'title',
'content',
'level',
'source_url',
'starts_at',
'ends_at',
'upstream_created_at',
'upstream_updated_at',
'first_seen_at',
'last_seen_at',
'read_at',
'dismissed_at',
'raw_payload',
],
values: [
asNumber(row.id, 0),
asNumber(row.siteId, 0),
asNullableString(row.platform),
asNullableString(row.sourceKey),
asNullableString(row.title),
asNullableString(row.content),
asNullableString(row.level) ?? 'info',
asNullableString(row.sourceUrl),
asNullableString(row.startsAt),
asNullableString(row.endsAt),
asNullableString(row.upstreamCreatedAt),
asNullableString(row.upstreamUpdatedAt),
asNullableString(row.firstSeenAt),
asNullableString(row.lastSeenAt),
asNullableString(row.readAt),
asNullableString(row.dismissedAt),
asNullableString(row.rawPayload),
],
});
}
for (const row of snapshot.accounts.accounts) {
statements.push({
table: 'accounts',
@@ -617,6 +665,7 @@ async function syncPostgresSequences(client: SqlClient): Promise<void> {
if (client.dialect !== 'postgres') return;
const tables = [
'sites',
'site_announcements',
'site_disabled_models',
'accounts',
'account_tokens',
@@ -684,6 +733,7 @@ export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Pro
timestamp: snapshot.timestamp,
rows: {
sites: snapshot.accounts.sites.length,
siteAnnouncements: snapshot.accounts.siteAnnouncements.length,
siteDisabledModels: snapshot.accounts.siteDisabledModels.length,
accounts: snapshot.accounts.accounts.length,
accountTokens: snapshot.accounts.accountTokens.length,
@@ -116,6 +116,12 @@ describe('downstreamApiKeyService', () => {
expect(service.isModelAllowedByPolicy('gemini-2.0-flash', result.policy)).toBe(false);
});
it('keeps all explicitly selected supported models when list exceeds 200 items', () => {
const selectedModels = Array.from({ length: 260 }, (_, index) => `model-${String(index + 1).padStart(3, '0')}`);
expect(service.normalizeSupportedModelsInput(selectedModels)).toEqual(selectedModels);
});
it('treats selected groups as additional allowed exposed route scope (union semantics)', async () => {
const claudeGroup = await db.insert(schema.tokenRoutes).values({
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
@@ -136,6 +142,18 @@ describe('downstreamApiKeyService', () => {
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gemini-2.0-flash', policy)).toBe(false);
});
it('denies all models when both supportedModels and allowedRouteIds are empty', async () => {
const policy = {
supportedModels: [],
allowedRouteIds: [],
siteWeightMultipliers: {},
denyAllWhenEmpty: true,
};
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(false);
});
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',
+22 -11
View File
@@ -90,13 +90,25 @@ function normalizePositiveIntegerOrNull(value: unknown): number | null {
return normalized;
}
function parseJson(value: string | null | undefined): unknown {
if (!value) return null;
try {
return JSON.parse(value);
} catch {
return null;
function parseJson(value: unknown): unknown {
if (value === null || value === undefined) return null;
// PostgreSQL JSONB columns are returned as parsed objects/arrays by the pg driver
if (typeof value === 'object') {
return value;
}
// SQLite TEXT columns store JSON as strings that need parsing
if (typeof value === 'string') {
if (value === '') return null;
try {
return JSON.parse(value);
} catch {
return null;
}
}
return null;
}
export function normalizeGroupNameInput(input: unknown): string | null {
@@ -131,16 +143,14 @@ export function normalizeSupportedModelsInput(input: unknown): string[] {
if (Array.isArray(input)) {
return input
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index)
.slice(0, 200);
.filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index);
}
if (typeof input === 'string') {
return input
.split(/\r?\n|,/g)
.map((item) => item.trim())
.filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index)
.slice(0, 200);
.filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index);
}
return [];
@@ -234,7 +244,7 @@ export async function isModelAllowedByPolicyOrAllowedRoutes(model: string, polic
const hasPatternRules = patterns.length > 0;
const hasRouteRules = allowedRouteIds.length > 0;
if (!hasPatternRules && !hasRouteRules) return true;
if (!hasPatternRules && !hasRouteRules) return policy.denyAllWhenEmpty === true ? false : true;
if (hasPatternRules && patterns.some((pattern) => matchesDownstreamModelPattern(model, pattern))) {
return true;
@@ -278,6 +288,7 @@ export function toPolicyFromView(view: Pick<DownstreamApiKeyPolicyView, 'support
supportedModels: normalizeSupportedModelsInput(view.supportedModels),
allowedRouteIds: normalizeAllowedRouteIdsInput(view.allowedRouteIds),
siteWeightMultipliers: normalizeSiteWeightMultipliersInput(view.siteWeightMultipliers),
denyAllWhenEmpty: true,
};
}
@@ -2,6 +2,7 @@ export interface DownstreamRoutingPolicy {
supportedModels: string[];
allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>;
denyAllWhenEmpty?: boolean;
}
export const EMPTY_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = {
@@ -248,6 +248,55 @@ describe('refreshModelsForAccount credential discovery', () => {
expect(parsed.runtimeHealth?.checkedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
it('does not scan hidden managed tokens for direct apikey connections', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => (
token === 'sk-direct-credential' ? ['gpt-4.1'] : ['legacy-should-not-be-used']
));
const site = await db.insert(schema.sites).values({
name: 'apikey-direct-site',
url: 'https://apikey-direct.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'apikey-direct-user',
accessToken: '',
apiToken: 'sk-direct-credential',
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
const hiddenToken = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'legacy-hidden',
token: 'sk-legacy-hidden',
source: 'legacy',
enabled: true,
isDefault: true,
}).returning().get();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
status: 'success',
modelCount: 1,
modelsPreview: ['gpt-4.1'],
tokenScanned: 0,
discoveredByCredential: true,
});
const tokenRows = await db.select().from(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, hiddenToken.id))
.all();
expect(tokenRows).toHaveLength(0);
});
it('returns structured result when account missing', async () => {
const result = await refreshModelsForAccount(9999);
+61
View File
@@ -85,6 +85,67 @@ describe('rebuildTokenRoutesFromAvailability', () => {
expect(channels[0]?.manualOverride).toBe(false);
});
it('ignores hidden account_tokens for direct apikey connections when rebuilding routes', async () => {
const site = await db.insert(schema.sites).values({
name: 'apikey-legacy-site',
url: 'https://apikey-legacy.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'apikey-legacy-user',
accessToken: '',
apiToken: 'sk-direct-credential',
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
const hiddenToken = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'legacy-hidden',
token: 'sk-hidden-legacy-token',
source: 'legacy',
enabled: true,
isDefault: true,
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-4.1',
available: true,
latencyMs: 200,
checkedAt: '2026-03-20T08:00:00.000Z',
}).run();
await db.insert(schema.tokenModelAvailability).values({
tokenId: hiddenToken.id,
modelName: 'gpt-4.1',
available: true,
latencyMs: 180,
checkedAt: '2026-03-20T08:00:00.000Z',
}).run();
const rebuild = await rebuildTokenRoutesFromAvailability();
expect(rebuild.models).toBe(1);
const route = await db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.modelPattern, 'gpt-4.1'))
.get();
expect(route).toBeDefined();
const channels = await db.select().from(schema.routeChannels)
.where(and(
eq(schema.routeChannels.routeId, route!.id),
eq(schema.routeChannels.accountId, account.id),
))
.all();
expect(channels).toHaveLength(1);
expect(channels[0]?.tokenId ?? null).toBeNull();
});
it('creates an exact route with an account-direct channel for oauth model availability', async () => {
const site = await db.insert(schema.sites).values({
name: 'codex-site',
+17 -16
View File
@@ -13,6 +13,7 @@ import {
getCredentialModeFromExtraConfig,
getProxyUrlFromExtraConfig,
mergeAccountExtraConfig,
requiresManagedAccountTokens,
resolvePlatformUserId,
supportsDirectAccountRoutingConnection,
} from './accountExtraConfig.js';
@@ -142,12 +143,6 @@ function isSiteDisabled(status?: string | null): boolean {
return (status || 'active') === 'disabled';
}
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
if (explicit && explicit !== 'auto') return explicit === 'apikey';
return !(account.accessToken || '').trim();
}
function normalizeModels(models: string[]): string[] {
const normalizedModels: string[] = [];
const seen = new Set<string>();
@@ -828,18 +823,21 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
} catch { }
}
let enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(
eq(schema.accountTokens.accountId, account.id),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.all();
const usesManagedTokens = requiresManagedAccountTokens(account);
let enabledTokens = usesManagedTokens
? await db.select()
.from(schema.accountTokens)
.where(and(
eq(schema.accountTokens.accountId, account.id),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.all()
: [];
enabledTokens = enabledTokens.filter(isUsableAccountToken);
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
if (!isApiKeyConnection(account) && enabledTokens.length === 0) {
if (usesManagedTokens && enabledTokens.length === 0) {
const fallback = discoveredApiToken || account.apiToken || null;
if (fallback) {
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
@@ -1027,7 +1025,10 @@ export async function rebuildTokenRoutesFromAvailability() {
),
)
.all();
const usableTokenRows = tokenRows.filter((row) => isUsableAccountToken(row.account_tokens));
const usableTokenRows = tokenRows.filter((row) => (
isUsableAccountToken(row.account_tokens)
&& requiresManagedAccountTokens(row.accounts)
));
const accountRows = await db.select().from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
+86 -2
View File
@@ -17,12 +17,24 @@ vi.mock('undici', () => ({
fetch: (...args: unknown[]) => fetchMock(...args),
}));
const withExplicitProxyRequestInitMock = vi.fn(
(_proxyUrl: unknown, options?: Record<string, unknown>) => {
if (_proxyUrl) return { ...(options || {}), dispatcher: 'mock-proxy-dispatcher' };
return options ?? {};
},
);
vi.mock('./siteProxy.js', () => ({
withExplicitProxyRequestInit: (...args: unknown[]) => withExplicitProxyRequestInitMock(...args),
}));
describe('notifyService', () => {
beforeEach(async () => {
vi.resetModules();
sendMailMock.mockReset();
createTransportMock.mockClear();
fetchMock.mockReset();
withExplicitProxyRequestInitMock.mockClear();
const { config } = await import('../config.js');
config.notifyCooldownSec = 300;
@@ -35,6 +47,9 @@ describe('notifyService', () => {
(config as any).telegramEnabled = false;
(config as any).telegramBotToken = '';
(config as any).telegramChatId = '';
(config as any).telegramUseSystemProxy = false;
config.systemProxyUrl = '';
(config as any).telegramMessageThreadId = '';
config.smtpEnabled = true;
config.smtpHost = 'smtp.example.com';
config.smtpPort = 465;
@@ -167,11 +182,12 @@ describe('notifyService', () => {
expect(payload?.text || '').toContain('UTC Time:');
});
it('sends telegram message when telegram channel is enabled', async () => {
it('sends telegram message without topic when telegram thread id is empty', async () => {
const { config } = await import('../config.js');
(config as any).telegramEnabled = true;
(config as any).telegramBotToken = '123456:telegram-token';
(config as any).telegramChatId = '-1001234567890';
(config as any).telegramMessageThreadId = '';
config.smtpEnabled = false;
fetchMock.mockResolvedValue({
@@ -192,13 +208,35 @@ describe('notifyService', () => {
);
const rawBody = fetchMock.mock.calls[0]?.[1] as { body?: string };
const payload = JSON.parse(rawBody?.body || '{}') as { chat_id?: string; text?: string };
const payload = JSON.parse(rawBody?.body || '{}') as { chat_id?: string; text?: string; message_thread_id?: number };
expect(payload.chat_id).toBe('-1001234567890');
expect(payload.message_thread_id).toBeUndefined();
expect(payload.text || '').toContain('Level: warning');
expect(payload.text || '').toContain('Local Time:');
expect(payload.text || '').toContain('UTC Time:');
});
it('sends telegram topic id when telegram thread id is configured', async () => {
const { config } = await import('../config.js');
(config as any).telegramEnabled = true;
(config as any).telegramBotToken = '123456:telegram-token';
(config as any).telegramChatId = '-1001234567890';
(config as any).telegramMessageThreadId = '77';
config.smtpEnabled = false;
fetchMock.mockResolvedValue({
ok: true,
status: 200,
});
const { sendNotification } = await import('./notifyService.js');
await sendNotification('测试通知', 'message', 'warning', { bypassThrottle: true, throwOnFailure: true });
const rawBody = fetchMock.mock.calls[0]?.[1] as { body?: string };
const payload = JSON.parse(rawBody?.body || '{}') as { message_thread_id?: number };
expect(payload.message_thread_id).toBe(77);
});
it('uses TELEGRAM_API_BASE_URL when configured', async () => {
const { config } = await import('../config.js');
(config as any).telegramEnabled = true;
@@ -222,4 +260,50 @@ describe('notifyService', () => {
}),
);
});
it('applies system proxy dispatcher when telegramUseSystemProxy is enabled', async () => {
const { config } = await import('../config.js');
(config as any).telegramEnabled = true;
(config as any).telegramBotToken = '123456:telegram-token';
(config as any).telegramChatId = '-1001234567890';
(config as any).telegramUseSystemProxy = true;
config.systemProxyUrl = 'http://127.0.0.1:7890';
config.smtpEnabled = false;
fetchMock.mockResolvedValue({ ok: true, status: 200 });
const { sendNotification } = await import('./notifyService.js');
await sendNotification('测试通知', 'proxy-test', 'info', { bypassThrottle: true, throwOnFailure: true });
expect(withExplicitProxyRequestInitMock).toHaveBeenCalledWith(
'http://127.0.0.1:7890',
expect.objectContaining({ method: 'POST' }),
);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/sendMessage'),
expect.objectContaining({ dispatcher: 'mock-proxy-dispatcher' }),
);
});
it('does not apply proxy dispatcher when telegramUseSystemProxy is disabled', async () => {
const { config } = await import('../config.js');
(config as any).telegramEnabled = true;
(config as any).telegramBotToken = '123456:telegram-token';
(config as any).telegramChatId = '-1001234567890';
(config as any).telegramUseSystemProxy = false;
config.systemProxyUrl = 'http://127.0.0.1:7890';
config.smtpEnabled = false;
fetchMock.mockResolvedValue({ ok: true, status: 200 });
const { sendNotification } = await import('./notifyService.js');
await sendNotification('测试通知', 'no-proxy-test', 'info', { bypassThrottle: true, throwOnFailure: true });
expect(withExplicitProxyRequestInitMock).toHaveBeenCalledWith(
null,
expect.objectContaining({ method: 'POST' }),
);
const fetchOptions = fetchMock.mock.calls[0]?.[1] as Record<string, unknown>;
expect(fetchOptions.dispatcher).toBeUndefined();
});
});
+18 -9
View File
@@ -1,5 +1,6 @@
import { fetch } from 'undici';
import { config } from '../config.js';
import { withExplicitProxyRequestInit } from './siteProxy.js';
import nodemailer, { type Transporter } from 'nodemailer';
import {
createNotificationSignature,
@@ -221,18 +222,26 @@ export async function sendNotification(
const telegramApiBaseUrl = String(config.telegramApiBaseUrl || 'https://api.telegram.org').replace(/\/+$/, '');
const telegramApiUrl = `${telegramApiBaseUrl}/bot${config.telegramBotToken}/sendMessage`;
const text = buildTelegramText(title, resolvedMessage, level, timeFootnote);
const telegramMessageThreadId = Number.parseInt(String(config.telegramMessageThreadId || '').trim(), 10);
tasks.push({
channel: 'telegram',
run: async () => {
const response = await fetch(telegramApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: config.telegramChatId,
text,
disable_web_page_preview: true,
}),
});
const telegramRequestInit = withExplicitProxyRequestInit(
config.telegramUseSystemProxy ? config.systemProxyUrl : null,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: config.telegramChatId,
...(Number.isFinite(telegramMessageThreadId) && telegramMessageThreadId > 0
? { message_thread_id: telegramMessageThreadId }
: {}),
text,
disable_web_page_preview: true,
}),
},
);
const response = await fetch(telegramApiUrl, telegramRequestInit);
if (!response.ok) {
throw new Error(`Telegram 响应状态 ${response.status}`);
}
+312
View File
@@ -0,0 +1,312 @@
import { minimatch } from 'minimatch';
export type PayloadRuleModel = {
name: string;
protocol?: string;
};
export type PayloadValueRule = {
models: PayloadRuleModel[];
params: Record<string, unknown>;
};
export type PayloadFilterRule = {
models: PayloadRuleModel[];
params: string[];
};
export type PayloadRulesConfig = {
default: PayloadValueRule[];
defaultRaw: PayloadValueRule[];
override: PayloadValueRule[];
overrideRaw: PayloadValueRule[];
filter: PayloadFilterRule[];
};
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function cloneJsonValue<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => cloneJsonValue(item)) as T;
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
) as T;
}
return value;
}
function toPathSegments(path: string): string[] {
const normalized = asTrimmedString(path).replace(/^\.+/, '');
return normalized
.split('.')
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0);
}
function parseIndexSegment(segment: string): number | null {
if (!/^\d+$/.test(segment)) return null;
const parsed = Number.parseInt(segment, 10);
return Number.isFinite(parsed) ? parsed : null;
}
function hasPath(target: unknown, path: string): boolean {
const segments = toPathSegments(path);
if (segments.length <= 0) return false;
let current: unknown = target;
for (const segment of segments) {
const index = parseIndexSegment(segment);
if (index !== null) {
if (!Array.isArray(current) || index >= current.length) return false;
current = current[index];
continue;
}
if (!isRecord(current) || !Object.prototype.hasOwnProperty.call(current, segment)) return false;
current = current[segment];
}
return true;
}
function setPath(target: Record<string, unknown>, path: string, value: unknown): void {
const segments = toPathSegments(path);
if (segments.length <= 0) return;
let current: unknown = target;
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index];
const nextSegment = segments[index + 1];
const segmentIndex = parseIndexSegment(segment);
const isLast = index === segments.length - 1;
if (segmentIndex !== null) {
if (!Array.isArray(current)) return;
while (current.length <= segmentIndex) current.push(undefined);
if (isLast) {
current[segmentIndex] = cloneJsonValue(value);
return;
}
if (!isRecord(current[segmentIndex]) && !Array.isArray(current[segmentIndex])) {
current[segmentIndex] = parseIndexSegment(nextSegment) !== null ? [] : {};
}
current = current[segmentIndex];
continue;
}
if (!isRecord(current)) return;
if (isLast) {
current[segment] = cloneJsonValue(value);
return;
}
if (!isRecord(current[segment]) && !Array.isArray(current[segment])) {
current[segment] = parseIndexSegment(nextSegment) !== null ? [] : {};
}
current = current[segment];
}
}
function deletePath(target: Record<string, unknown>, path: string): void {
const segments = toPathSegments(path);
if (segments.length <= 0) return;
let current: unknown = target;
for (let index = 0; index < segments.length - 1; index += 1) {
const segment = segments[index];
const segmentIndex = parseIndexSegment(segment);
if (segmentIndex !== null) {
if (!Array.isArray(current) || segmentIndex >= current.length) return;
current = current[segmentIndex];
continue;
}
if (!isRecord(current) || !Object.prototype.hasOwnProperty.call(current, segment)) return;
current = current[segment];
}
const lastSegment = segments[segments.length - 1];
const lastIndex = parseIndexSegment(lastSegment);
if (lastIndex !== null) {
if (!Array.isArray(current) || lastIndex >= current.length) return;
current.splice(lastIndex, 1);
return;
}
if (!isRecord(current)) return;
delete current[lastSegment];
}
function normalizePayloadRuleModels(value: unknown): PayloadRuleModel[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (!isRecord(item)) return null;
const name = asTrimmedString(item.name);
if (!name) return null;
const protocol = asTrimmedString(item.protocol);
return {
name,
...(protocol ? { protocol } : {}),
};
})
.filter((item): item is PayloadRuleModel => !!item);
}
function normalizePayloadValueRules(value: unknown): PayloadValueRule[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (!isRecord(item)) return null;
const models = normalizePayloadRuleModels(item.models);
const params = isRecord(item.params) ? cloneJsonValue(item.params) : null;
if (models.length <= 0 || !params) return null;
return { models, params };
})
.filter((item): item is PayloadValueRule => !!item);
}
function normalizePayloadFilterRules(value: unknown): PayloadFilterRule[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (!isRecord(item)) return null;
const models = normalizePayloadRuleModels(item.models);
const params = Array.isArray(item.params)
? item.params
.map((entry) => asTrimmedString(entry))
.filter((entry) => entry.length > 0)
: [];
if (models.length <= 0 || params.length <= 0) return null;
return { models, params };
})
.filter((item): item is PayloadFilterRule => !!item);
}
function modelRuleMatches(rule: PayloadRuleModel, protocol: string, candidates: string[]): boolean {
if (!rule.name || candidates.length <= 0) return false;
const ruleProtocol = asTrimmedString(rule.protocol).toLowerCase();
if (ruleProtocol && protocol && ruleProtocol !== protocol) return false;
return candidates.some((candidate) => minimatch(candidate, rule.name, { nocase: true }));
}
function rulesMatch(models: PayloadRuleModel[], protocol: string, candidates: string[]): boolean {
if (models.length <= 0 || candidates.length <= 0) return false;
return models.some((rule) => modelRuleMatches(rule, protocol, candidates));
}
function parseRawRuleValue(value: unknown): unknown {
if (typeof value !== 'string') return cloneJsonValue(value);
const trimmed = value.trim();
if (!trimmed) return undefined;
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
export function createEmptyPayloadRulesConfig(): PayloadRulesConfig {
return {
default: [],
defaultRaw: [],
override: [],
overrideRaw: [],
filter: [],
};
}
export function normalizePayloadRulesConfig(value: unknown): PayloadRulesConfig {
if (!isRecord(value)) return createEmptyPayloadRulesConfig();
return {
default: normalizePayloadValueRules(value.default),
defaultRaw: normalizePayloadValueRules(value.defaultRaw ?? value['default-raw']),
override: normalizePayloadValueRules(value.override),
overrideRaw: normalizePayloadValueRules(value.overrideRaw ?? value['override-raw']),
filter: normalizePayloadFilterRules(value.filter),
};
}
export function applyPayloadRules(input: {
rules: PayloadRulesConfig;
payload: Record<string, unknown>;
modelName?: string;
requestedModel?: string;
protocol?: string;
}): Record<string, unknown> {
const candidates = Array.from(new Set(
[input.modelName, input.requestedModel]
.map((value) => asTrimmedString(value))
.filter((value) => value.length > 0),
));
if (candidates.length <= 0) return input.payload;
const rules = input.rules;
const hasAnyRules = rules.default.length > 0
|| rules.defaultRaw.length > 0
|| rules.override.length > 0
|| rules.overrideRaw.length > 0
|| rules.filter.length > 0;
if (!hasAnyRules) return input.payload;
const protocol = asTrimmedString(input.protocol).toLowerCase();
const original = cloneJsonValue(input.payload);
const output = cloneJsonValue(input.payload);
const appliedDefaults = new Set<string>();
for (const rule of rules.default) {
if (!rulesMatch(rule.models, protocol, candidates)) continue;
for (const [path, value] of Object.entries(rule.params)) {
const normalizedPath = asTrimmedString(path);
if (!normalizedPath || hasPath(original, normalizedPath) || appliedDefaults.has(normalizedPath)) continue;
setPath(output, normalizedPath, value);
appliedDefaults.add(normalizedPath);
}
}
for (const rule of rules.defaultRaw) {
if (!rulesMatch(rule.models, protocol, candidates)) continue;
for (const [path, value] of Object.entries(rule.params)) {
const normalizedPath = asTrimmedString(path);
if (!normalizedPath || hasPath(original, normalizedPath) || appliedDefaults.has(normalizedPath)) continue;
const parsed = parseRawRuleValue(value);
if (parsed === undefined) continue;
setPath(output, normalizedPath, parsed);
appliedDefaults.add(normalizedPath);
}
}
for (const rule of rules.override) {
if (!rulesMatch(rule.models, protocol, candidates)) continue;
for (const [path, value] of Object.entries(rule.params)) {
const normalizedPath = asTrimmedString(path);
if (!normalizedPath) continue;
setPath(output, normalizedPath, value);
}
}
for (const rule of rules.overrideRaw) {
if (!rulesMatch(rule.models, protocol, candidates)) continue;
for (const [path, value] of Object.entries(rule.params)) {
const normalizedPath = asTrimmedString(path);
if (!normalizedPath) continue;
const parsed = parseRawRuleValue(value);
if (parsed === undefined) continue;
setPath(output, normalizedPath, parsed);
}
}
for (const rule of rules.filter) {
if (!rulesMatch(rule.models, protocol, candidates)) continue;
for (const path of rule.params) {
const normalizedPath = asTrimmedString(path);
if (!normalizedPath) continue;
deletePath(output, normalizedPath);
}
}
return output;
}
+49
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import type { RequestInit as UndiciRequestInit } from 'undici';
import { withSiteProxyRequestInit } from '../siteProxy.js';
@@ -7,12 +8,33 @@ export interface CheckinResult {
reward?: string;
}
export interface SubscriptionPlanSummary {
id?: number;
groupId?: number;
groupName?: string;
status?: string;
expiresAt?: string;
dailyUsedUsd?: number;
dailyLimitUsd?: number;
weeklyUsedUsd?: number;
weeklyLimitUsd?: number;
monthlyUsedUsd?: number;
monthlyLimitUsd?: number;
}
export interface SubscriptionSummary {
activeCount: number;
totalUsedUsd: number;
subscriptions: SubscriptionPlanSummary[];
}
export interface BalanceInfo {
balance: number;
used: number;
quota: number;
todayIncome?: number;
todayQuotaConsumption?: number;
subscriptionSummary?: SubscriptionSummary;
}
interface LoginResult {
@@ -44,6 +66,19 @@ export interface ApiTokenInfo {
tokenGroup?: string | null;
}
export interface SiteAnnouncement {
sourceKey: string;
title: string;
content: string;
level: 'info' | 'warning' | 'error';
sourceUrl?: string | null;
startsAt?: string | null;
endsAt?: string | null;
upstreamCreatedAt?: string | null;
upstreamUpdatedAt?: string | null;
rawPayload?: unknown;
}
export interface CreateApiTokenOptions {
name?: string;
group?: string;
@@ -66,6 +101,7 @@ export interface PlatformAdapter {
getModels(baseUrl: string, token: string, platformUserId?: number): Promise<string[]>;
getApiToken(baseUrl: string, accessToken: string, platformUserId?: number): Promise<string | null>;
getApiTokens(baseUrl: string, accessToken: string, platformUserId?: number): Promise<ApiTokenInfo[]>;
getSiteAnnouncements(baseUrl: string, accessToken: string, platformUserId?: number): Promise<SiteAnnouncement[]>;
getUserGroups(baseUrl: string, accessToken: string, platformUserId?: number): Promise<string[]>;
createApiToken(baseUrl: string, accessToken: string, platformUserId?: number, options?: CreateApiTokenOptions): Promise<boolean>;
deleteApiToken(baseUrl: string, accessToken: string, tokenKey: string, platformUserId?: number): Promise<boolean>;
@@ -149,6 +185,14 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
return [{ name: 'default', key: token, enabled: true, tokenGroup: 'default' }];
}
async getSiteAnnouncements(
_baseUrl: string,
_accessToken: string,
_platformUserId?: number,
): Promise<SiteAnnouncement[]> {
return [];
}
async createApiToken(
_baseUrl: string,
_accessToken: string,
@@ -192,4 +236,9 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
}
return res.json() as Promise<T>;
}
protected buildNoticeSourceKey(content: string): string {
const normalized = (content || '').trim();
return `notice:${createHash('sha1').update(normalized).digest('hex')}`;
}
}
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { AddressInfo } from 'node:net';
import { createHash } from 'node:crypto';
import { DoneHubAdapter } from './doneHub.js';
describe('DoneHubAdapter', () => {
@@ -48,6 +49,15 @@ describe('DoneHubAdapter', () => {
return;
}
if (req.url === '/api/notice') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
data: 'Scheduled maintenance tonight',
}));
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
@@ -93,4 +103,20 @@ describe('DoneHubAdapter', () => {
expect(balance.used).toBe(60);
expect(balance.quota).toBe(100);
});
it('normalizes the global site notice from /api/notice', async () => {
const adapter = new DoneHubAdapter();
const rows = await adapter.getSiteAnnouncements(baseUrl, 'token');
expect(rows).toEqual([
{
sourceKey: `notice:${createHash('sha1').update('Scheduled maintenance tonight').digest('hex')}`,
title: 'Site notice',
content: 'Scheduled maintenance tonight',
level: 'info',
sourceUrl: '/api/notice',
rawPayload: { success: true, data: 'Scheduled maintenance tonight' },
},
]);
});
});
+21 -1
View File
@@ -1,5 +1,5 @@
import { OneHubAdapter } from './oneHub.js';
import type { BalanceInfo, CheckinResult } from './base.js';
import type { BalanceInfo, CheckinResult, SiteAnnouncement } from './base.js';
export class DoneHubAdapter extends OneHubAdapter {
readonly platformName: string = 'done-hub';
@@ -32,6 +32,26 @@ export class DoneHubAdapter extends OneHubAdapter {
return { balance: quotaRemaining, used, quota: total, todayIncome, todayQuotaConsumption };
}
override async getSiteAnnouncements(baseUrl: string, _accessToken: string): Promise<SiteAnnouncement[]> {
try {
const payload = await this.fetchJson<any>(`${baseUrl}/api/notice`);
const content = typeof payload?.data === 'string'
? payload.data.trim()
: (typeof payload === 'string' ? payload.trim() : '');
if (!content) return [];
return [{
sourceKey: this.buildNoticeSourceKey(content),
title: 'Site notice',
content,
level: 'info',
sourceUrl: '/api/notice',
rawPayload: payload,
}];
} catch {
return [];
}
}
// getModels is inherited from OneHubAdapter which already has /api/available_model fallback.
// No need to override here — OneHub's implementation handles this correctly.
}
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { AddressInfo } from 'node:net';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { NewApiAdapter } from './newApi.js';
@@ -142,6 +143,15 @@ describe('NewApiAdapter', () => {
return;
}
if (req.url === '/api/notice') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
data: 'Welcome to the site',
}));
return;
}
if (req.url?.startsWith('/api/token/')) {
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_SHIELDED_TOKEN}`) {
res.writeHead(401, { 'Content-Type': 'application/json' });
@@ -616,4 +626,20 @@ describe('NewApiAdapter', () => {
expect(receivedHeaders['rix-api-user']).toBe('42');
expect(receivedHeaders['neo-api-user']).toBe('42');
});
it('normalizes the global site notice from /api/notice', async () => {
const adapter = new NewApiAdapter();
const rows = await adapter.getSiteAnnouncements(baseUrl, 'session-token');
expect(rows).toEqual([
{
sourceKey: `notice:${createHash('sha1').update('Welcome to the site').digest('hex')}`,
title: 'Site notice',
content: 'Welcome to the site',
level: 'info',
sourceUrl: '/api/notice',
rawPayload: { success: true, data: 'Welcome to the site' },
},
]);
});
});
+21 -1
View File
@@ -1,4 +1,4 @@
import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo, TokenVerifyResult, CreateApiTokenOptions } from './base.js';
import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo, TokenVerifyResult, CreateApiTokenOptions, type SiteAnnouncement } from './base.js';
import type { RequestInit as UndiciRequestInit } from 'undici';
import { createContext, runInContext } from 'node:vm';
import { withSiteProxyRequestInit } from '../siteProxy.js';
@@ -15,6 +15,26 @@ export class NewApiAdapter extends BasePlatformAdapter {
}
}
override async getSiteAnnouncements(baseUrl: string, _accessToken: string): Promise<SiteAnnouncement[]> {
try {
const payload = await this.fetchJson<any>(`${baseUrl}/api/notice`);
const content = typeof payload?.data === 'string'
? payload.data.trim()
: (typeof payload === 'string' ? payload.trim() : '');
if (!content) return [];
return [{
sourceKey: this.buildNoticeSourceKey(content),
title: 'Site notice',
content,
level: 'info',
sourceUrl: '/api/notice',
rawPayload: payload,
}];
} catch {
return [];
}
}
private tryDecodeUserId(token: string): number | null {
try {
const parts = token.split('.');
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import {
BasePlatformAdapter,
type BalanceInfo,
type CheckinResult,
type PlatformAdapter,
type SiteAnnouncement,
} from './base.js';
class UnsupportedAnnouncementAdapter extends BasePlatformAdapter {
override readonly platformName = 'unsupported';
override async detect(): Promise<boolean> {
return false;
}
override async checkin(): Promise<CheckinResult> {
return { success: false, message: 'unsupported' };
}
override async getBalance(): Promise<BalanceInfo> {
return { balance: 0, used: 0, quota: 0 };
}
override async getModels(): Promise<string[]> {
return [];
}
}
class SingleNoticeAdapter extends UnsupportedAnnouncementAdapter {
override readonly platformName = 'single-notice';
override async getSiteAnnouncements(): Promise<SiteAnnouncement[]> {
return [{
sourceKey: 'notice:welcome',
title: 'Site notice',
content: 'Welcome to the site',
level: 'info',
sourceUrl: '/api/notice',
rawPayload: { notice: 'Welcome to the site' },
}];
}
}
class ListAnnouncementAdapter extends UnsupportedAnnouncementAdapter {
override readonly platformName = 'list-notice';
override async getSiteAnnouncements(): Promise<SiteAnnouncement[]> {
return [
{
sourceKey: 'announcement:1',
title: 'Maintenance',
content: 'Window starts at 10:00',
level: 'warning',
startsAt: '2026-03-20T10:00:00Z',
upstreamCreatedAt: '2026-03-20T09:00:00Z',
rawPayload: { id: 1 },
},
{
sourceKey: 'announcement:2',
title: 'New model online',
content: 'gpt-4.1 is available',
level: 'info',
endsAt: '2026-03-21T00:00:00Z',
upstreamUpdatedAt: '2026-03-20T12:00:00Z',
rawPayload: { id: 2 },
},
];
}
}
function expectAnnouncementShape(row: SiteAnnouncement) {
expect(row.sourceKey).toBeTruthy();
expect(row.title).toBeTruthy();
expect(row.content).toBeTruthy();
expect(['info', 'warning', 'error']).toContain(row.level);
}
describe('site announcement platform contract', () => {
it('allows unsupported adapters to return an empty list', async () => {
const adapter: PlatformAdapter = new UnsupportedAnnouncementAdapter();
await expect(adapter.getSiteAnnouncements('https://example.com', 'token')).resolves.toEqual([]);
});
it('normalizes single-notice platforms into the shared announcement shape', async () => {
const adapter: PlatformAdapter = new SingleNoticeAdapter();
const rows = await adapter.getSiteAnnouncements('https://example.com', 'token');
expect(rows).toHaveLength(1);
expectAnnouncementShape(rows[0]);
});
it('normalizes list-style platforms into the shared announcement shape', async () => {
const adapter: PlatformAdapter = new ListAnnouncementAdapter();
const rows = await adapter.getSiteAnnouncements('https://example.com', 'token');
expect(rows).toHaveLength(2);
rows.forEach(expectAnnouncementShape);
});
});
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { Sub2ApiAdapter } from './sub2api.js';
class MockSub2ApiAdapter extends Sub2ApiAdapter {
constructor(private readonly responses: Record<string, unknown>) {
super();
}
protected override async fetchJson<T>(url: string): Promise<T> {
const parsed = new URL(url);
const key = `${parsed.pathname}${parsed.search}`;
const value = this.responses[key];
if (value instanceof Error) throw value;
if (value === undefined) throw new Error(`Unexpected request: ${key}`);
return value as T;
}
}
describe('Sub2ApiAdapter subscription summary parsing', () => {
it('returns summary data from /api/v1/subscriptions/summary', async () => {
const adapter = new MockSub2ApiAdapter({
'/api/v1/auth/me': {
code: 0,
message: 'success',
data: { id: 1, username: 'demo', email: 'demo@example.com', balance: 8.5 },
},
'/api/v1/subscriptions/summary': {
code: 0,
message: 'success',
data: {
active_count: 1,
total_used_usd: 3.2,
subscriptions: [
{
id: 9,
group_name: 'Pro',
status: 'active',
expires_at: '2026-04-01T00:00:00Z',
monthly_used_usd: 3.2,
monthly_limit_usd: 20,
},
],
},
},
});
const balance = await adapter.getBalance('https://sub2api.example.com', 'jwt-token');
expect(balance.subscriptionSummary).toEqual({
activeCount: 1,
totalUsedUsd: 3.2,
subscriptions: [
{
id: 9,
groupName: 'Pro',
status: 'active',
expiresAt: '2026-04-01T00:00:00.000Z',
monthlyUsedUsd: 3.2,
monthlyLimitUsd: 20,
},
],
});
});
it('falls back to /api/v1/subscriptions/active when summary endpoint fails', async () => {
const adapter = new MockSub2ApiAdapter({
'/api/v1/auth/me': {
code: 0,
message: 'success',
data: { id: 1, username: 'demo', email: 'demo@example.com', balance: 8.5 },
},
'/api/v1/subscriptions/summary': new Error('HTTP 404'),
'/api/v1/subscriptions/active': {
code: 0,
message: 'success',
data: [
{
id: 10,
group_name: 'Fallback',
expires_at: '2026-05-01T00:00:00Z',
monthly_used_usd: 1.5,
monthly_limit_usd: 10,
},
],
},
});
const balance = await adapter.getBalance('https://sub2api.example.com', 'jwt-token');
expect(balance.subscriptionSummary).toEqual({
activeCount: 1,
totalUsedUsd: 1.5,
subscriptions: [
{
id: 10,
groupName: 'Fallback',
expiresAt: '2026-05-01T00:00:00.000Z',
monthlyUsedUsd: 1.5,
monthlyLimitUsd: 10,
},
],
});
});
});
@@ -101,6 +101,111 @@ describe('Sub2ApiAdapter', () => {
expect(balance.used).toBe(0);
});
it('includes subscription summary from /api/v1/subscriptions/summary when available', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 12.5 },
}));
return;
}
if (req.url === '/api/v1/subscriptions/summary') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: {
active_count: 1,
total_used_usd: 3.75,
subscriptions: [
{
id: 3,
group_name: 'Pro',
status: 'active',
expires_at: '2026-04-01T00:00:00Z',
monthly_used_usd: 3.75,
monthly_limit_usd: 20,
},
],
},
}));
return;
}
res.writeHead(404).end();
});
const balance = await adapter.getBalance(baseUrl, 'jwt-token');
expect(balance.subscriptionSummary).toEqual({
activeCount: 1,
totalUsedUsd: 3.75,
subscriptions: [
{
id: 3,
groupName: 'Pro',
status: 'active',
expiresAt: '2026-04-01T00:00:00.000Z',
monthlyUsedUsd: 3.75,
monthlyLimitUsd: 20,
},
],
});
});
it('falls back to active subscriptions when summary endpoint is unavailable', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 12.5 },
}));
return;
}
if (req.url === '/api/v1/subscriptions/summary') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ code: 404, message: 'not found' }));
return;
}
if (req.url === '/api/v1/subscriptions/active') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: [
{
id: 9,
group_name: 'Fallback',
expires_at: '2026-05-01T00:00:00Z',
monthly_used_usd: 2.5,
monthly_limit_usd: 15,
},
],
}));
return;
}
res.writeHead(404).end();
});
const balance = await adapter.getBalance(baseUrl, 'jwt-token');
expect(balance.subscriptionSummary).toEqual({
activeCount: 1,
totalUsedUsd: 2.5,
subscriptions: [
{
id: 9,
groupName: 'Fallback',
expiresAt: '2026-05-01T00:00:00.000Z',
monthlyUsedUsd: 2.5,
monthlyLimitUsd: 15,
},
],
});
});
it('fetches user info from /api/v1/auth/me', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me') {
@@ -438,4 +543,79 @@ describe('Sub2ApiAdapter', () => {
const deleted = await adapter.deleteApiToken(baseUrl, 'jwt-token', 'sk-delete-me');
expect(deleted).toBe(true);
});
it('normalizes announcements from /api/v1/announcements', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/announcements?page=1&page_size=100') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: {
items: [
{
id: 11,
title: 'Maintenance',
content: 'Window starts at 10:00',
starts_at: '2026-03-20T10:00:00Z',
ends_at: '2026-03-20T12:00:00Z',
created_at: '2026-03-20T09:00:00Z',
updated_at: '2026-03-20T09:30:00Z',
},
{
id: 12,
title: 'New model online',
content: 'gpt-4.1 is available',
read_at: '2026-03-20T12:05:00Z',
created_at: '2026-03-20T12:00:00Z',
updated_at: '2026-03-20T12:01:00Z',
},
],
},
}));
return;
}
res.writeHead(404).end();
});
const rows = await adapter.getSiteAnnouncements(baseUrl, 'jwt-token');
expect(rows).toEqual([
{
sourceKey: 'announcement:11',
title: 'Maintenance',
content: 'Window starts at 10:00',
level: 'info',
startsAt: '2026-03-20T10:00:00Z',
endsAt: '2026-03-20T12:00:00Z',
upstreamCreatedAt: '2026-03-20T09:00:00Z',
upstreamUpdatedAt: '2026-03-20T09:30:00Z',
rawPayload: {
id: 11,
title: 'Maintenance',
content: 'Window starts at 10:00',
starts_at: '2026-03-20T10:00:00Z',
ends_at: '2026-03-20T12:00:00Z',
created_at: '2026-03-20T09:00:00Z',
updated_at: '2026-03-20T09:30:00Z',
},
},
{
sourceKey: 'announcement:12',
title: 'New model online',
content: 'gpt-4.1 is available',
level: 'info',
upstreamCreatedAt: '2026-03-20T12:00:00Z',
upstreamUpdatedAt: '2026-03-20T12:01:00Z',
rawPayload: {
id: 12,
title: 'New model online',
content: 'gpt-4.1 is available',
read_at: '2026-03-20T12:05:00Z',
created_at: '2026-03-20T12:00:00Z',
updated_at: '2026-03-20T12:01:00Z',
},
},
]);
});
});
+231 -1
View File
@@ -4,6 +4,9 @@ import {
CheckinResult,
BalanceInfo,
CreateApiTokenOptions,
SubscriptionPlanSummary,
SubscriptionSummary,
type SiteAnnouncement,
UserInfo,
} from './base.js';
@@ -21,6 +24,193 @@ function normalizeBaseUrl(baseUrl: string): string {
export class Sub2ApiAdapter extends BasePlatformAdapter {
readonly platformName = 'sub2api';
private roundCurrency(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}
private parsePositiveInteger(raw: unknown): number | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return Math.trunc(raw);
if (typeof raw === 'string') {
const parsed = Number.parseInt(raw.trim(), 10);
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
}
return undefined;
}
private parseNonNegativeNumber(raw: unknown): number | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
return this.roundCurrency(raw);
}
if (typeof raw === 'string') {
const parsed = Number(raw.trim());
if (Number.isFinite(parsed) && parsed >= 0) {
return this.roundCurrency(parsed);
}
}
return undefined;
}
private parseDateTime(raw: unknown): string | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
const ms = raw > 10_000_000_000 ? raw : raw * 1000;
return new Date(ms).toISOString();
}
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
const numeric = Number(trimmed);
if (Number.isFinite(numeric) && numeric > 0) {
const ms = numeric > 10_000_000_000 ? numeric : numeric * 1000;
return new Date(ms).toISOString();
}
const parsed = Date.parse(trimmed);
if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
return undefined;
}
private parseSubscriptionItem(raw: unknown): SubscriptionPlanSummary | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const item = raw as Record<string, unknown>;
const group = item.group && typeof item.group === 'object' && !Array.isArray(item.group)
? item.group as Record<string, unknown>
: null;
const normalized: SubscriptionPlanSummary = {};
const id = this.parsePositiveInteger(item.id);
if (id) normalized.id = id;
const groupId = this.parsePositiveInteger(item.group_id ?? item.groupId ?? group?.id);
if (groupId) normalized.groupId = groupId;
const groupNameCandidates = [
item.group_name,
item.groupName,
item.name,
item.title,
group?.name,
group?.title,
];
for (const candidate of groupNameCandidates) {
if (typeof candidate !== 'string') continue;
const trimmed = candidate.trim();
if (!trimmed) continue;
normalized.groupName = trimmed;
break;
}
if (typeof item.status === 'string' && item.status.trim()) {
normalized.status = item.status.trim();
}
const expiresAt = this.parseDateTime(
item.expires_at
?? item.expiresAt
?? item.expired_at
?? item.expiredAt
?? item.end_at
?? item.endAt
?? item.end_time
?? item.endTime
?? item.current_period_end
?? item.currentPeriodEnd,
);
if (expiresAt) normalized.expiresAt = expiresAt;
const dailyUsedUsd = this.parseNonNegativeNumber(item.daily_used_usd ?? item.dailyUsedUsd);
if (dailyUsedUsd !== undefined) normalized.dailyUsedUsd = dailyUsedUsd;
const dailyLimitUsd = this.parseNonNegativeNumber(item.daily_limit_usd ?? item.dailyLimitUsd);
if (dailyLimitUsd !== undefined) normalized.dailyLimitUsd = dailyLimitUsd;
const weeklyUsedUsd = this.parseNonNegativeNumber(item.weekly_used_usd ?? item.weeklyUsedUsd);
if (weeklyUsedUsd !== undefined) normalized.weeklyUsedUsd = weeklyUsedUsd;
const weeklyLimitUsd = this.parseNonNegativeNumber(item.weekly_limit_usd ?? item.weeklyLimitUsd);
if (weeklyLimitUsd !== undefined) normalized.weeklyLimitUsd = weeklyLimitUsd;
const monthlyUsedUsd = this.parseNonNegativeNumber(
item.monthly_used_usd
?? item.monthlyUsedUsd
?? item.used_usd
?? item.usedUsd
?? item.total_used_usd
?? item.totalUsedUsd,
);
if (monthlyUsedUsd !== undefined) normalized.monthlyUsedUsd = monthlyUsedUsd;
const monthlyLimitUsd = this.parseNonNegativeNumber(
item.monthly_limit_usd
?? item.monthlyLimitUsd
?? item.limit_usd
?? item.limitUsd
?? item.total_limit_usd
?? item.totalLimitUsd,
);
if (monthlyLimitUsd !== undefined) normalized.monthlyLimitUsd = monthlyLimitUsd;
return Object.keys(normalized).length > 0 ? normalized : null;
}
private parseSubscriptionItems(raw: unknown): SubscriptionPlanSummary[] {
const rawItems = (() => {
if (Array.isArray(raw)) return raw;
if (raw && typeof raw === 'object') {
const body = raw as Record<string, unknown>;
if (Array.isArray(body.subscriptions)) return body.subscriptions;
if (Array.isArray(body.items)) return body.items;
if (Array.isArray(body.list)) return body.list;
if (Array.isArray(body.data)) return body.data;
}
return [];
})();
return rawItems
.map((item) => this.parseSubscriptionItem(item))
.filter((item): item is SubscriptionPlanSummary => !!item);
}
private buildSubscriptionSummary(payload: unknown): SubscriptionSummary {
const body = payload && typeof payload === 'object' && !Array.isArray(payload)
? payload as Record<string, unknown>
: {};
const subscriptions = this.parseSubscriptionItems(payload);
const activeCount = this.parsePositiveInteger(body.active_count ?? body.activeCount);
const totalUsedUsd = this.parseNonNegativeNumber(body.total_used_usd ?? body.totalUsedUsd);
const inferredUsedUsd = subscriptions.reduce((sum, item) => sum + (item.monthlyUsedUsd || 0), 0);
return {
activeCount: activeCount ?? subscriptions.length,
totalUsedUsd: totalUsedUsd ?? this.roundCurrency(inferredUsedUsd),
subscriptions,
};
}
private async fetchSubscriptionSummary(baseUrl: string, accessToken: string): Promise<SubscriptionSummary | undefined> {
const headers = this.buildAuthHeader(accessToken);
const summaryEndpoint = '/api/v1/subscriptions/summary';
try {
const res = await this.fetchJson<any>(`${baseUrl}${summaryEndpoint}`, { headers });
const data = this.parseSub2ApiEnvelope<any>(res, summaryEndpoint);
return this.buildSubscriptionSummary(data);
} catch {}
const fallbackEndpoints = ['/api/v1/subscriptions/active'];
for (const endpoint of fallbackEndpoints) {
try {
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, { headers });
const data = this.parseSub2ApiEnvelope<any>(res, endpoint);
return this.buildSubscriptionSummary(data);
} catch {}
}
return undefined;
}
private stripBearerPrefix(value?: string | null): string {
const trimmed = (value || '').trim();
if (!trimmed) return '';
@@ -461,13 +651,18 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
// --- Balance ---
async getBalance(baseUrl: string, accessToken: string): Promise<BalanceInfo> {
const user = await this.fetchAuthMe(baseUrl, accessToken);
const normalizedBase = normalizeBaseUrl(baseUrl);
const [user, subscriptionSummary] = await Promise.all([
this.fetchAuthMe(normalizedBase, accessToken),
this.fetchSubscriptionSummary(normalizedBase, accessToken),
]);
const quotaValue = this.usdToQuota(user.balance);
// Sub2API only provides current balance, no usage breakdown
return {
balance: quotaValue / 500000,
used: 0,
quota: quotaValue / 500000,
subscriptionSummary,
};
}
@@ -486,6 +681,41 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
return this.fetchModelsByToken(normalizedBase, discoveredApiToken);
}
override async getSiteAnnouncements(baseUrl: string, accessToken: string): Promise<SiteAnnouncement[]> {
try {
const endpoint = '/api/v1/announcements?page=1&page_size=100';
const res = await this.fetchJson<any>(`${normalizeBaseUrl(baseUrl)}${endpoint}`, {
headers: this.buildAuthHeader(accessToken),
});
const data = this.parseSub2ApiEnvelope<any>(res, endpoint);
const rawItems = Array.isArray(data)
? data
: (Array.isArray(data?.items) ? data.items : []);
const rows: SiteAnnouncement[] = [];
for (const item of rawItems) {
const id = Number.parseInt(String(item?.id), 10);
if (!Number.isFinite(id) || id <= 0) continue;
const title = typeof item?.title === 'string' ? item.title.trim() : '';
const content = typeof item?.content === 'string' ? item.content.trim() : '';
if (!title && !content) continue;
rows.push({
sourceKey: `announcement:${id}`,
title: title || `Announcement ${id}`,
content: content || title,
level: 'info',
startsAt: typeof item?.starts_at === 'string' ? item.starts_at : undefined,
endsAt: typeof item?.ends_at === 'string' ? item.ends_at : undefined,
upstreamCreatedAt: typeof item?.created_at === 'string' ? item.created_at : undefined,
upstreamUpdatedAt: typeof item?.updated_at === 'string' ? item.updated_at : undefined,
rawPayload: item,
});
}
return rows;
} catch {
return [];
}
}
override async getApiTokens(baseUrl: string, accessToken: string): Promise<ApiTokenInfo[]> {
try {
const keys = await this.listApiKeys(normalizeBaseUrl(baseUrl), accessToken);
@@ -0,0 +1,112 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
type DbModule = typeof import('../db/index.js');
type StoreModule = typeof import('./proxyFileStore.js');
type RetentionModule = typeof import('./proxyFileRetentionService.js');
type ConfigModule = typeof import('../config.js');
describe('proxyFileRetentionService', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let store: StoreModule;
let retention: RetentionModule;
let config: ConfigModule['config'];
let dataDir = '';
let originalRetentionDays = 0;
let originalPruneIntervalMinutes = 0;
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-proxy-file-retention-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const storeModule = await import('./proxyFileStore.js');
const retentionModule = await import('./proxyFileRetentionService.js');
const configModule = await import('../config.js');
db = dbModule.db;
schema = dbModule.schema;
store = storeModule;
retention = retentionModule;
config = configModule.config;
originalRetentionDays = config.proxyFileRetentionDays;
originalPruneIntervalMinutes = config.proxyFileRetentionPruneIntervalMinutes;
});
beforeEach(async () => {
config.proxyFileRetentionDays = originalRetentionDays;
config.proxyFileRetentionPruneIntervalMinutes = originalPruneIntervalMinutes;
await db.delete(schema.proxyFiles).run();
});
afterAll(async () => {
config.proxyFileRetentionDays = originalRetentionDays;
config.proxyFileRetentionPruneIntervalMinutes = originalPruneIntervalMinutes;
const dbModule = await import('../db/index.js');
await dbModule.closeDbConnections();
delete process.env.DATA_DIR;
});
it('returns disabled when proxy file retention is turned off', async () => {
config.proxyFileRetentionDays = 0;
const result = await retention.cleanupExpiredProxyFiles(Date.parse('2026-03-20T00:00:00Z'));
expect(result).toEqual({
enabled: false,
retentionDays: 0,
cutoffUtc: null,
deleted: 0,
});
});
it('purges files older than the configured retention window', async () => {
config.proxyFileRetentionDays = 7;
await db.insert(schema.proxyFiles).values([
{
publicId: 'file-metapi-old',
ownerType: 'global_proxy_token',
ownerId: 'global',
filename: 'old.txt',
mimeType: 'text/plain',
purpose: 'assistants',
byteSize: 3,
sha256: 'old-hash',
contentBase64: Buffer.from('old').toString('base64'),
createdAt: '2026-03-10 00:00:00',
updatedAt: '2026-03-10 00:00:00',
deletedAt: null,
},
{
publicId: 'file-metapi-new',
ownerType: 'global_proxy_token',
ownerId: 'global',
filename: 'new.txt',
mimeType: 'text/plain',
purpose: 'assistants',
byteSize: 3,
sha256: 'new-hash',
contentBase64: Buffer.from('new').toString('base64'),
createdAt: '2026-03-18 00:00:00',
updatedAt: '2026-03-18 00:00:00',
deletedAt: null,
},
]).run();
const result = await retention.cleanupExpiredProxyFiles(Date.parse('2026-03-20T00:00:00Z'));
expect(result).toEqual({
enabled: true,
retentionDays: 7,
cutoffUtc: '2026-03-13 00:00:00',
deleted: 1,
});
const remaining = await store.listProxyFilesByOwner({ ownerType: 'global_proxy_token', ownerId: 'global' });
expect(remaining.map((item) => item.publicId)).toEqual(['file-metapi-new']);
});
});
@@ -0,0 +1,63 @@
import { config } from '../config.js';
import { getLogCleanupCutoffUtc } from './logCleanupService.js';
import { purgeExpiredProxyFiles } from './proxyFileStore.js';
let retentionTimer: ReturnType<typeof setInterval> | null = null;
export function getProxyFileRetentionCutoffUtc(nowMs = Date.now()): string | null {
const days = Math.max(0, Math.trunc(config.proxyFileRetentionDays));
if (days <= 0) return null;
return getLogCleanupCutoffUtc(days, nowMs);
}
export async function cleanupExpiredProxyFiles(nowMs = Date.now()): Promise<{
enabled: boolean;
retentionDays: number;
cutoffUtc: string | null;
deleted: number;
}> {
const retentionDays = Math.max(0, Math.trunc(config.proxyFileRetentionDays));
const cutoffUtc = getProxyFileRetentionCutoffUtc(nowMs);
if (!cutoffUtc) {
return {
enabled: false,
retentionDays,
cutoffUtc: null,
deleted: 0,
};
}
const deleted = await purgeExpiredProxyFiles(cutoffUtc);
return {
enabled: true,
retentionDays,
cutoffUtc,
deleted,
};
}
export function startProxyFileRetentionService(): void {
if (retentionTimer) return;
const intervalMinutes = Math.max(1, Math.trunc(config.proxyFileRetentionPruneIntervalMinutes));
const intervalMs = intervalMinutes * 60 * 1000;
const runCleanup = async () => {
try {
const result = await cleanupExpiredProxyFiles();
if (!result.enabled || result.deleted <= 0) return;
console.info(`[proxy-file-retention] deleted ${result.deleted} files before ${result.cutoffUtc}`);
} catch (error) {
console.warn('[proxy-file-retention] cleanup failed', error);
}
};
void runCleanup();
retentionTimer = setInterval(() => { void runCleanup(); }, intervalMs);
retentionTimer.unref?.();
}
export function stopProxyFileRetentionService(): void {
if (!retentionTimer) return;
clearInterval(retentionTimer);
retentionTimer = null;
}
@@ -112,4 +112,45 @@ describe('proxyFileStore', () => {
});
expect(loaded).toBeNull();
});
it('purges expired proxy files before a cutoff to control database growth', async () => {
await db.insert(schema.proxyFiles).values([
{
publicId: 'file-metapi-old',
ownerType: 'global_proxy_token',
ownerId: 'global',
filename: 'old.txt',
mimeType: 'text/plain',
purpose: 'assistants',
byteSize: 3,
sha256: 'old-hash',
contentBase64: Buffer.from('old').toString('base64'),
createdAt: '2026-03-01 00:00:00',
updatedAt: '2026-03-01 00:00:00',
deletedAt: null,
},
{
publicId: 'file-metapi-new',
ownerType: 'global_proxy_token',
ownerId: 'global',
filename: 'new.txt',
mimeType: 'text/plain',
purpose: 'assistants',
byteSize: 3,
sha256: 'new-hash',
contentBase64: Buffer.from('new').toString('base64'),
createdAt: '2026-03-19 00:00:00',
updatedAt: '2026-03-19 00:00:00',
deletedAt: null,
},
]).run();
const purgeExpiredProxyFiles = (store as Record<string, any>).purgeExpiredProxyFiles;
const deleted = await purgeExpiredProxyFiles?.('2026-03-10 00:00:00');
expect(deleted).toBe(1);
const remainingRows = await db.select().from(schema.proxyFiles).all();
expect(remainingRows.map((row) => row.publicId)).toEqual(['file-metapi-new']);
});
});
+15 -1
View File
@@ -1,4 +1,4 @@
import { and, desc, eq, isNull, or } from 'drizzle-orm';
import { and, desc, eq, isNull, lt, or } from 'drizzle-orm';
import { createHash } from 'node:crypto';
import { db, schema } from '../db/index.js';
import type { ProxyResourceOwner } from '../middleware/auth.js';
@@ -204,3 +204,17 @@ export async function softDeleteProxyFile(publicId: string): Promise<boolean> {
.run();
return Number(result?.changes || 0) > 0;
}
export async function purgeExpiredProxyFiles(cutoffUtc: string): Promise<number> {
const normalizedCutoff = cutoffUtc.trim();
if (!normalizedCutoff) return 0;
const result = await db.delete(schema.proxyFiles)
.where(or(
lt(schema.proxyFiles.createdAt, normalizedCutoff),
and(isNull(schema.proxyFiles.createdAt), lt(schema.proxyFiles.updatedAt, normalizedCutoff)),
))
.run();
return Number(result?.changes || 0);
}
+2 -11
View File
@@ -1,6 +1,7 @@
import type { ProxyResourceOwner } from '../middleware/auth.js';
import { getProxyFileByPublicIdForOwner, LOCAL_PROXY_FILE_ID_PREFIX } from './proxyFileStore.js';
import { ensureBase64DataUrl } from '../transformers/shared/inputFile.js';
import { summarizeConversationFileInputsInOpenAiBody } from '../proxy-core/capabilities/conversationFileCapabilities.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
@@ -306,15 +307,5 @@ export async function resolveResponsesBodyInputFiles(
}
export function hasNonImageFileInputInOpenAiBody(body: Record<string, unknown>): boolean {
const messages = Array.isArray(body.messages) ? body.messages : [];
return messages.some((message) => {
if (!isRecord(message)) return false;
const content = message.content;
if (isRecord(content)) {
const type = asTrimmedString(content.type).toLowerCase();
return type === 'file' || type === 'input_file';
}
if (!Array.isArray(content)) return false;
return content.some((item) => isRecord(item) && ['file', 'input_file'].includes(asTrimmedString(item.type).toLowerCase()));
});
return summarizeConversationFileInputsInOpenAiBody(body).hasDocument;
}
+61
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
hasProxyLogBillingDetailsColumnMock,
hasProxyLogClientColumnsMock,
hasProxyLogDownstreamApiKeyIdColumnMock,
dbInsertMock,
dbInsertValuesMock,
@@ -9,6 +10,7 @@ const {
proxyLogsSchema,
} = vi.hoisted(() => ({
hasProxyLogBillingDetailsColumnMock: vi.fn(),
hasProxyLogClientColumnsMock: vi.fn(),
hasProxyLogDownstreamApiKeyIdColumnMock: vi.fn(),
dbInsertMock: vi.fn(),
dbInsertValuesMock: vi.fn(),
@@ -28,6 +30,10 @@ const {
totalTokens: 'total_tokens',
estimatedCost: 'estimated_cost',
billingDetails: 'billing_details',
clientFamily: 'client_family',
clientAppId: 'client_app_id',
clientAppName: 'client_app_name',
clientConfidence: 'client_confidence',
errorMessage: 'error_message',
retryCount: 'retry_count',
createdAt: 'created_at',
@@ -42,6 +48,7 @@ vi.mock('../db/index.js', () => ({
proxyLogs: proxyLogsSchema,
},
hasProxyLogBillingDetailsColumn: (...args: unknown[]) => hasProxyLogBillingDetailsColumnMock(...args),
hasProxyLogClientColumns: (...args: unknown[]) => hasProxyLogClientColumnsMock(...args),
hasProxyLogDownstreamApiKeyIdColumn: (...args: unknown[]) => hasProxyLogDownstreamApiKeyIdColumnMock(...args),
}));
@@ -50,11 +57,13 @@ import { insertProxyLog, withProxyLogSelectFields } from './proxyLogStore.js';
describe('proxyLogStore', () => {
beforeEach(() => {
hasProxyLogBillingDetailsColumnMock.mockReset();
hasProxyLogClientColumnsMock.mockReset();
hasProxyLogDownstreamApiKeyIdColumnMock.mockReset();
dbInsertMock.mockReset();
dbInsertValuesMock.mockReset();
dbInsertRunMock.mockReset();
hasProxyLogBillingDetailsColumnMock.mockResolvedValue(false);
hasProxyLogClientColumnsMock.mockResolvedValue(false);
hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(false);
dbInsertMock.mockReturnValue({
@@ -132,4 +141,56 @@ describe('proxyLogStore', () => {
expect(dbInsertValuesMock.mock.calls[2][0].billingDetails).toBeUndefined();
expect(dbInsertValuesMock.mock.calls[2][0].downstreamApiKeyId).toBeUndefined();
});
it('writes structured client fields when the schema supports them', async () => {
hasProxyLogClientColumnsMock.mockResolvedValue(true);
await insertProxyLog({
modelRequested: 'gpt-5',
clientFamily: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
});
expect(dbInsertValuesMock).toHaveBeenCalledTimes(1);
expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({
modelRequested: 'gpt-5',
clientFamily: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
});
});
it('retries proxy log inserts without structured client fields when those columns are missing', async () => {
hasProxyLogClientColumnsMock.mockResolvedValue(true);
dbInsertRunMock
.mockRejectedValueOnce(new Error('column proxy_logs.client_app_id does not exist'))
.mockResolvedValueOnce(undefined);
await insertProxyLog({
modelRequested: 'gpt-5',
clientFamily: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
});
expect(dbInsertValuesMock).toHaveBeenCalledTimes(2);
expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({
modelRequested: 'gpt-5',
clientFamily: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
});
expect(dbInsertValuesMock.mock.calls[1][0]).toMatchObject({
modelRequested: 'gpt-5',
});
expect(dbInsertValuesMock.mock.calls[1][0].clientFamily).toBeUndefined();
expect(dbInsertValuesMock.mock.calls[1][0].clientAppId).toBeUndefined();
expect(dbInsertValuesMock.mock.calls[1][0].clientAppName).toBeUndefined();
expect(dbInsertValuesMock.mock.calls[1][0].clientConfidence).toBeUndefined();
});
});
+146 -60
View File
@@ -1,4 +1,10 @@
import { db, schema, hasProxyLogBillingDetailsColumn, hasProxyLogDownstreamApiKeyIdColumn } from '../db/index.js';
import {
db,
schema,
hasProxyLogBillingDetailsColumn,
hasProxyLogClientColumns,
hasProxyLogDownstreamApiKeyIdColumn,
} from '../db/index.js';
export type ProxyLogInsertInput = {
routeId?: number | null;
@@ -15,12 +21,16 @@ export type ProxyLogInsertInput = {
totalTokens?: number | null;
estimatedCost?: number | null;
billingDetails?: unknown;
clientFamily?: string | null;
clientAppId?: string | null;
clientAppName?: string | null;
clientConfidence?: string | null;
errorMessage?: string | null;
retryCount?: number | null;
createdAt?: string | null;
};
function buildProxyLogBaseSelectFields() {
function buildProxyLogCoreSelectFields() {
return {
id: schema.proxyLogs.id,
routeId: schema.proxyLogs.routeId,
@@ -42,47 +52,93 @@ function buildProxyLogBaseSelectFields() {
};
}
export function getProxyLogBaseSelectFields() {
return buildProxyLogBaseSelectFields();
function buildProxyLogClientSelectFields() {
return {
clientFamily: schema.proxyLogs.clientFamily,
clientAppId: schema.proxyLogs.clientAppId,
clientAppName: schema.proxyLogs.clientAppName,
clientConfidence: schema.proxyLogs.clientConfidence,
};
}
export type ProxyLogSelectFields = ReturnType<typeof buildProxyLogBaseSelectFields> & {
billingDetails?: typeof schema.proxyLogs.billingDetails;
};
function buildProxyLogSelectFields(options?: {
includeBillingDetails?: boolean;
includeClientFields?: boolean;
}) {
return {
...buildProxyLogCoreSelectFields(),
...(options?.includeClientFields ? buildProxyLogClientSelectFields() : {}),
...(options?.includeBillingDetails ? { billingDetails: schema.proxyLogs.billingDetails } : {}),
};
}
export function getProxyLogBaseSelectFields() {
return buildProxyLogCoreSelectFields();
}
export type ProxyLogSelectFields = ReturnType<typeof buildProxyLogSelectFields>;
export type ResolvedProxyLogSelectFields = {
includeBillingDetails: boolean;
includeClientFields: boolean;
fields: ProxyLogSelectFields;
};
export async function resolveProxyLogSelectFields(options?: { includeBillingDetails?: boolean }) {
export async function resolveProxyLogSelectFields(options?: {
includeBillingDetails?: boolean;
includeClientFields?: boolean;
}) {
const includeBillingDetails = options?.includeBillingDetails === true
&& await hasProxyLogBillingDetailsColumn();
const includeClientFields = options?.includeClientFields !== false
&& await hasProxyLogClientColumns();
return {
includeBillingDetails,
fields: includeBillingDetails
? { ...buildProxyLogBaseSelectFields(), billingDetails: schema.proxyLogs.billingDetails }
: buildProxyLogBaseSelectFields(),
includeClientFields,
fields: buildProxyLogSelectFields({
includeBillingDetails,
includeClientFields,
}),
};
}
export async function withProxyLogSelectFields<T>(
runner: (selection: ResolvedProxyLogSelectFields) => Promise<T>,
options?: { includeBillingDetails?: boolean },
options?: { includeBillingDetails?: boolean; includeClientFields?: boolean },
): Promise<T> {
const selection = await resolveProxyLogSelectFields(options);
let selection = await resolveProxyLogSelectFields(options);
try {
return await runner(selection);
} catch (error) {
if (selection.includeBillingDetails && isMissingBillingDetailsColumnError(error)) {
return await runner({
includeBillingDetails: false,
fields: buildProxyLogBaseSelectFields(),
});
while (true) {
try {
return await runner(selection);
} catch (error) {
if (selection.includeBillingDetails && isMissingBillingDetailsColumnError(error)) {
selection = {
includeBillingDetails: false,
includeClientFields: selection.includeClientFields,
fields: buildProxyLogSelectFields({
includeBillingDetails: false,
includeClientFields: selection.includeClientFields,
}),
};
continue;
}
if (selection.includeClientFields && isMissingProxyLogClientColumnsError(error)) {
selection = {
includeBillingDetails: selection.includeBillingDetails,
includeClientFields: false,
fields: buildProxyLogSelectFields({
includeBillingDetails: selection.includeBillingDetails,
includeClientFields: false,
}),
};
continue;
}
throw error;
}
throw error;
}
}
@@ -96,11 +152,15 @@ export function parseProxyLogBillingDetails(value: unknown): Record<string, unkn
}
}
export function isMissingBillingDetailsColumnError(error: unknown): boolean {
function normalizeProxyLogStoreErrorMessage(error: unknown): string {
const message = typeof error === 'object' && error && 'message' in error
? String((error as { message?: unknown }).message || '')
: String(error || '');
const lowered = message.toLowerCase();
return message.toLowerCase();
}
export function isMissingBillingDetailsColumnError(error: unknown): boolean {
const lowered = normalizeProxyLogStoreErrorMessage(error);
return lowered.includes('billing_details')
&& (
lowered.includes('does not exist')
@@ -111,10 +171,7 @@ export function isMissingBillingDetailsColumnError(error: unknown): boolean {
}
export function isMissingDownstreamApiKeyIdColumnError(error: unknown): boolean {
const message = typeof error === 'object' && error && 'message' in error
? String((error as { message?: unknown }).message || '')
: String(error || '');
const lowered = message.toLowerCase();
const lowered = normalizeProxyLogStoreErrorMessage(error);
return lowered.includes('downstream_api_key_id')
&& (
lowered.includes('does not exist')
@@ -124,6 +181,24 @@ export function isMissingDownstreamApiKeyIdColumnError(error: unknown): boolean
);
}
export function isMissingProxyLogClientColumnsError(error: unknown): boolean {
const lowered = normalizeProxyLogStoreErrorMessage(error);
const hasClientColumnReference = [
'client_family',
'client_app_id',
'client_app_name',
'client_confidence',
].some((columnName) => lowered.includes(columnName));
return hasClientColumnReference
&& (
lowered.includes('does not exist')
|| lowered.includes('unknown column')
|| lowered.includes('no such column')
|| lowered.includes('has no column named')
);
}
export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void> {
const baseValues = {
routeId: input.routeId ?? null,
@@ -149,43 +224,54 @@ export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void>
&& await hasProxyLogBillingDetailsColumn();
const includeDownstreamApiKeyId = input.downstreamApiKeyId != null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const requestedClientFields = [
input.clientFamily,
input.clientAppId,
input.clientAppName,
input.clientConfidence,
].some((value) => value != null && String(value).trim().length > 0);
const includeClientFields = requestedClientFields
&& await hasProxyLogClientColumns();
try {
await db.insert(schema.proxyLogs).values(
includeBillingDetails || includeDownstreamApiKeyId
let allowBillingDetails = includeBillingDetails;
let allowDownstreamApiKeyId = includeDownstreamApiKeyId;
let allowClientFields = includeClientFields;
while (true) {
const values = {
...baseValues,
...(allowBillingDetails ? { billingDetails: serializedBillingDetails } : {}),
...(allowDownstreamApiKeyId ? { downstreamApiKeyId: input.downstreamApiKeyId } : {}),
...(allowClientFields
? {
...baseValues,
...(includeBillingDetails ? { billingDetails: serializedBillingDetails } : {}),
...(includeDownstreamApiKeyId ? { downstreamApiKeyId: input.downstreamApiKeyId } : {}),
clientFamily: input.clientFamily ?? null,
clientAppId: input.clientAppId ?? null,
clientAppName: input.clientAppName ?? null,
clientConfidence: input.clientConfidence ?? null,
}
: baseValues,
).run();
} catch (error) {
if (includeBillingDetails && isMissingBillingDetailsColumnError(error)) {
try {
await db.insert(schema.proxyLogs).values(
includeDownstreamApiKeyId
? { ...baseValues, downstreamApiKeyId: input.downstreamApiKeyId }
: baseValues,
).run();
} catch (fallbackError) {
if (includeDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(fallbackError)) {
await db.insert(schema.proxyLogs).values(baseValues).run();
return;
}
throw fallbackError;
}
return;
}
: {}),
};
if (includeDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(error)) {
await db.insert(schema.proxyLogs).values(
includeBillingDetails
? { ...baseValues, billingDetails: serializedBillingDetails }
: baseValues,
).run();
try {
await db.insert(schema.proxyLogs).values(values).run();
return;
} catch (error) {
if (allowBillingDetails && isMissingBillingDetailsColumnError(error)) {
allowBillingDetails = false;
continue;
}
if (allowDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(error)) {
allowDownstreamApiKeyId = false;
continue;
}
if (allowClientFields && isMissingProxyLogClientColumnsError(error)) {
allowClientFields = false;
continue;
}
throw error;
}
throw error;
}
}
+12 -6
View File
@@ -20,10 +20,19 @@ describe('proxyRetryPolicy', () => {
).toBe(true);
});
it('retries on generic client request errors for cross-channel fallback', () => {
it('does not retry obvious request-shape errors that will fail on every channel', () => {
expect(
shouldRetryProxyRequest(400, '{"error":{"message":"invalid request body"}}'),
).toBe(true);
).toBe(false);
expect(
shouldRetryProxyRequest(422, '{"error":{"message":"unprocessable"}}'),
).toBe(false);
expect(
shouldRetryProxyRequest(404, '{"error":{"message":"not found"}}'),
).toBe(false);
});
it('keeps retrying channel-local compatibility and auth failures', () => {
expect(
shouldRetryProxyRequest(401, '{"error":{"message":"invalid access token"}}'),
).toBe(true);
@@ -31,10 +40,7 @@ describe('proxyRetryPolicy', () => {
shouldRetryProxyRequest(403, '{"error":{"message":"forbidden"}}'),
).toBe(true);
expect(
shouldRetryProxyRequest(404, '{"error":{"message":"not found"}}'),
).toBe(true);
expect(
shouldRetryProxyRequest(422, '{"error":{"message":"unprocessable"}}'),
shouldRetryProxyRequest(400, 'Unsupported legacy protocol: /v1/chat/completions is not supported. Please use /v1/responses.'),
).toBe(true);
});
});
+51 -4
View File
@@ -14,14 +14,61 @@ const MODEL_UNSUPPORTED_PATTERNS: RegExp[] = [
/you\s+do\s+not\s+have\s+access\s+to\s+the\s+model/i,
];
const RETRYABLE_CHANNEL_LOCAL_PATTERNS: RegExp[] = [
/unsupported\s+legacy\s+protocol/i,
/please\s+use\s+\/v1\/responses/i,
/please\s+use\s+\/v1\/messages/i,
/please\s+use\s+\/v1\/chat\/completions/i,
/does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i,
/unsupported\s+endpoint/i,
/unsupported\s+path/i,
/unknown\s+endpoint/i,
/unrecognized\s+request\s+url/i,
/no\s+route\s+matched/i,
/invalid\s+api\s+key/i,
/invalid\s+access\s+token/i,
/forbidden/i,
/rate\s+limit/i,
/quota/i,
/bad\s+gateway/i,
/gateway\s+time-?out/i,
/service\s+unavailable/i,
/cpu\s+overloaded/i,
/timeout/i,
];
const NON_RETRYABLE_REQUEST_PATTERNS: RegExp[] = [
/invalid\s+request\s+body/i,
/validation/i,
/missing\s+required/i,
/required\s+parameter/i,
/unknown\s+parameter/i,
/unrecognized\s+(field|key|parameter)/i,
/malformed/i,
/invalid\s+json/i,
/cannot\s+parse/i,
/unsupported\s+media\s+type/i,
];
function isModelUnsupportedErrorMessage(rawMessage?: string | null): boolean {
const text = (rawMessage || '').trim();
if (!text) return false;
return MODEL_UNSUPPORTED_PATTERNS.some((pattern) => pattern.test(text));
}
export function shouldRetryProxyRequest(status: number, upstreamErrorText?: string | null): boolean {
if (status >= 400) return true;
if (!upstreamErrorText) return false;
return isModelUnsupportedErrorMessage(upstreamErrorText);
function matchesAnyPattern(patterns: RegExp[], rawMessage?: string | null): boolean {
const text = (rawMessage || '').trim();
if (!text) return false;
return patterns.some((pattern) => pattern.test(text));
}
export function shouldRetryProxyRequest(status: number, upstreamErrorText?: string | null): boolean {
if (status >= 500) return true;
if (status === 408 || status === 409 || status === 425 || status === 429) return true;
if (status === 401 || status === 403) return true;
if (isModelUnsupportedErrorMessage(upstreamErrorText)) return true;
if (matchesAnyPattern(NON_RETRYABLE_REQUEST_PATTERNS, upstreamErrorText)) return false;
if (matchesAnyPattern(RETRYABLE_CHANNEL_LOCAL_PATTERNS, upstreamErrorText)) return true;
if (status === 400 || status === 404 || status === 422) return false;
return false;
}
+2 -1
View File
@@ -1,10 +1,11 @@
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
export type RouteRoutingStrategy = 'weighted' | 'round_robin' | 'stable_first';
export const DEFAULT_ROUTE_ROUTING_STRATEGY: RouteRoutingStrategy = 'weighted';
export function normalizeRouteRoutingStrategy(value: unknown): RouteRoutingStrategy {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'round_robin') return 'round_robin';
if (normalized === 'stable_first') return 'stable_first';
return DEFAULT_ROUTE_ROUTING_STRATEGY;
}
@@ -0,0 +1,32 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const syncSiteAnnouncementsMock = vi.fn();
vi.mock('./siteAnnouncementService.js', () => ({
syncSiteAnnouncements: (...args: unknown[]) => syncSiteAnnouncementsMock(...args),
}));
describe('siteAnnouncementPollingService', () => {
beforeEach(() => {
vi.useFakeTimers();
syncSiteAnnouncementsMock.mockReset();
});
afterEach(async () => {
const module = await import('./siteAnnouncementPollingService.js');
module.stopSiteAnnouncementPolling();
vi.useRealTimers();
});
it('runs one immediate sync and then continues on the configured interval', async () => {
const module = await import('./siteAnnouncementPollingService.js');
syncSiteAnnouncementsMock.mockResolvedValue(undefined);
module.startSiteAnnouncementPolling(60_000);
await vi.advanceTimersByTimeAsync(0);
expect(syncSiteAnnouncementsMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(60_000);
expect(syncSiteAnnouncementsMock).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,35 @@
import { syncSiteAnnouncements } from './siteAnnouncementService.js';
const DEFAULT_SITE_ANNOUNCEMENT_INTERVAL_MS = 15 * 60 * 1000;
let pollingTimer: ReturnType<typeof setInterval> | null = null;
let syncRunning = false;
async function runSyncOnce() {
if (syncRunning) return;
syncRunning = true;
try {
await syncSiteAnnouncements();
} catch (error) {
console.error('[SiteAnnouncementPolling] Sync failed:', error);
} finally {
syncRunning = false;
}
}
export function startSiteAnnouncementPolling(intervalMs = DEFAULT_SITE_ANNOUNCEMENT_INTERVAL_MS) {
stopSiteAnnouncementPolling();
pollingTimer = setInterval(() => {
void runSyncOnce();
}, Math.max(10_000, intervalMs));
pollingTimer.unref?.();
void runSyncOnce();
return { intervalMs: Math.max(10_000, intervalMs) };
}
export function stopSiteAnnouncementPolling() {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = null;
}
}
@@ -0,0 +1,192 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const getAdapterMock = vi.fn();
const sendNotificationMock = vi.fn();
vi.mock('./platforms/index.js', () => ({
getAdapter: (...args: unknown[]) => getAdapterMock(...args),
}));
vi.mock('./notifyService.js', () => ({
sendNotification: (...args: unknown[]) => sendNotificationMock(...args),
}));
type DbModule = typeof import('../db/index.js');
type ServiceModule = typeof import('./siteAnnouncementService.js');
describe('siteAnnouncementService', () => {
let dataDir = '';
let db: DbModule['db'];
let schema: DbModule['schema'];
let closeDbConnections: DbModule['closeDbConnections'];
let syncSiteAnnouncements: ServiceModule['syncSiteAnnouncements'];
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-announcements-service-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const serviceModule = await import('./siteAnnouncementService.js');
db = dbModule.db;
schema = dbModule.schema;
closeDbConnections = dbModule.closeDbConnections;
syncSiteAnnouncements = serviceModule.syncSiteAnnouncements;
});
beforeEach(async () => {
vi.useFakeTimers();
getAdapterMock.mockReset();
sendNotificationMock.mockReset();
await db.delete(schema.siteAnnouncements).run();
await db.delete(schema.events).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
vi.useRealTimers();
if (typeof closeDbConnections === 'function') {
await closeDbConnections();
}
if (dataDir) {
try {
rmSync(dataDir, { recursive: true, force: true });
} catch {}
}
delete process.env.DATA_DIR;
});
it('stores first-seen announcements, creates one event, and sends one notification', async () => {
vi.setSystemTime(new Date('2026-03-20T10:00:00Z'));
const site = await db.insert(schema.sites).values({
name: 'Sub Site',
url: 'https://sub.example.com',
platform: 'sub2api',
status: 'active',
}).returning().get();
await db.insert(schema.accounts).values({
siteId: site.id,
username: 'demo-user',
accessToken: 'jwt-token',
status: 'active',
}).run();
await db.insert(schema.sites).values({
name: 'Unsupported Site',
url: 'https://unsupported.example.com',
platform: 'openai',
status: 'active',
}).run();
getAdapterMock.mockImplementation((platform: string) => {
if (platform === 'sub2api') {
return {
getSiteAnnouncements: vi.fn(async (_baseUrl: string, accessToken: string) => {
expect(accessToken).toBe('jwt-token');
return [{
sourceKey: 'announcement:11',
title: 'Maintenance',
content: 'Window starts at 10:00',
level: 'info',
rawPayload: { id: 11, title: 'Maintenance' },
}];
}),
};
}
return {
getSiteAnnouncements: undefined,
};
});
const result = await syncSiteAnnouncements();
expect(result).toMatchObject({
scannedSites: 2,
inserted: 1,
updated: 0,
unsupported: 1,
notifications: 1,
events: 1,
failed: 0,
});
const announcementRows = await db.select().from(schema.siteAnnouncements).all();
expect(announcementRows).toHaveLength(1);
expect(announcementRows[0]).toMatchObject({
siteId: site.id,
platform: 'sub2api',
sourceKey: 'announcement:11',
title: 'Maintenance',
content: 'Window starts at 10:00',
level: 'info',
});
const eventRows = await db.select().from(schema.events).all();
expect(eventRows).toHaveLength(1);
expect(eventRows[0]).toMatchObject({
type: 'site_notice',
relatedType: 'site_announcement',
});
expect(Number(eventRows[0]?.relatedId)).toBe(Number(announcementRows[0]?.id));
expect(sendNotificationMock).toHaveBeenCalledTimes(1);
expect(sendNotificationMock.mock.calls[0]?.[0]).toContain('Sub Site');
expect(sendNotificationMock.mock.calls[0]?.[1]).toContain('Window starts at 10:00');
expect(sendNotificationMock.mock.calls[0]?.[2]).toBe('info');
});
it('updates existing announcements without duplicating events or notifications', async () => {
const site = await db.insert(schema.sites).values({
name: 'Sub Site',
url: 'https://sub.example.com',
platform: 'sub2api',
status: 'active',
}).returning().get();
await db.insert(schema.accounts).values({
siteId: site.id,
username: 'demo-user',
accessToken: 'jwt-token',
status: 'active',
}).run();
getAdapterMock.mockReturnValue({
getSiteAnnouncements: vi.fn(async () => [{
sourceKey: 'announcement:11',
title: 'Maintenance',
content: 'Window starts at 10:00',
level: 'info',
rawPayload: { id: 11, title: 'Maintenance' },
}]),
});
vi.setSystemTime(new Date('2026-03-20T10:00:00Z'));
await syncSiteAnnouncements({ siteId: site.id });
const firstRow = await db.select().from(schema.siteAnnouncements).get();
vi.setSystemTime(new Date('2026-03-20T11:00:00Z'));
const result = await syncSiteAnnouncements({ siteId: site.id });
expect(result).toMatchObject({
scannedSites: 1,
inserted: 0,
updated: 1,
notifications: 0,
events: 0,
failed: 0,
});
const announcementRows = await db.select().from(schema.siteAnnouncements).all();
expect(announcementRows).toHaveLength(1);
expect(announcementRows[0]?.firstSeenAt).toBe(firstRow?.firstSeenAt);
expect(announcementRows[0]?.lastSeenAt).not.toBe(firstRow?.lastSeenAt);
const eventRows = await db.select().from(schema.events).all();
expect(eventRows).toHaveLength(1);
expect(sendNotificationMock).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,163 @@
import { and, asc, eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { getAdapter } from './platforms/index.js';
import { sendNotification } from './notifyService.js';
import { formatUtcSqlDateTime } from './localTimeService.js';
import type { SiteAnnouncement } from './platforms/base.js';
export type SiteAnnouncementSyncResult = {
scannedSites: number;
inserted: number;
updated: number;
unsupported: number;
notifications: number;
events: number;
failed: number;
failedSites: Array<{ siteId: number; siteName: string; message: string }>;
};
function toStoredPayload(rawPayload: unknown): string | null {
if (rawPayload === undefined) return null;
try {
return JSON.stringify(rawPayload);
} catch {
return null;
}
}
function buildAnnouncementMessage(row: SiteAnnouncement): string {
const title = String(row.title || '').trim();
const content = String(row.content || '').trim();
if (title && content && title !== content && title.toLowerCase() !== 'site notice') {
return `${title}\n${content}`;
}
return content || title;
}
async function resolveSiteAccessToken(siteId: number, siteApiKey?: string | null): Promise<string> {
const direct = String(siteApiKey || '').trim();
if (direct) return direct;
const account = await db.select()
.from(schema.accounts)
.where(and(
eq(schema.accounts.siteId, siteId),
eq(schema.accounts.status, 'active'),
))
.orderBy(asc(schema.accounts.id))
.limit(1)
.get();
return String(account?.accessToken || '').trim();
}
async function listTargetSites(siteId?: number | null) {
if (siteId && Number.isFinite(siteId) && siteId > 0) {
return await db.select()
.from(schema.sites)
.where(eq(schema.sites.id, siteId))
.all();
}
return await db.select()
.from(schema.sites)
.where(eq(schema.sites.status, 'active'))
.all();
}
export async function syncSiteAnnouncements(options?: { siteId?: number | null }): Promise<SiteAnnouncementSyncResult> {
const result: SiteAnnouncementSyncResult = {
scannedSites: 0,
inserted: 0,
updated: 0,
unsupported: 0,
notifications: 0,
events: 0,
failed: 0,
failedSites: [],
};
const sites = await listTargetSites(options?.siteId ?? null);
for (const site of sites) {
result.scannedSites += 1;
const adapter = getAdapter(String(site.platform || ''));
if (!adapter || typeof adapter.getSiteAnnouncements !== 'function') {
result.unsupported += 1;
continue;
}
try {
const accessToken = await resolveSiteAccessToken(site.id, site.apiKey);
const announcements = await adapter.getSiteAnnouncements(site.url, accessToken);
const seenAt = formatUtcSqlDateTime(new Date());
for (const announcement of announcements) {
const existing = await db.select()
.from(schema.siteAnnouncements)
.where(and(
eq(schema.siteAnnouncements.siteId, site.id),
eq(schema.siteAnnouncements.sourceKey, announcement.sourceKey),
))
.limit(1)
.get();
const patch = {
platform: String(site.platform || '').trim(),
title: announcement.title,
content: announcement.content,
level: announcement.level,
sourceUrl: announcement.sourceUrl ?? null,
startsAt: announcement.startsAt ?? null,
endsAt: announcement.endsAt ?? null,
upstreamCreatedAt: announcement.upstreamCreatedAt ?? null,
upstreamUpdatedAt: announcement.upstreamUpdatedAt ?? null,
lastSeenAt: seenAt,
rawPayload: toStoredPayload(announcement.rawPayload),
};
if (existing) {
await db.update(schema.siteAnnouncements)
.set(patch)
.where(eq(schema.siteAnnouncements.id, existing.id))
.run();
result.updated += 1;
continue;
}
const inserted = await db.insert(schema.siteAnnouncements).values({
siteId: site.id,
sourceKey: announcement.sourceKey,
firstSeenAt: seenAt,
...patch,
}).returning().get();
result.inserted += 1;
const title = `站点公告:${site.name}`;
const message = buildAnnouncementMessage(announcement);
await db.insert(schema.events).values({
type: 'site_notice',
title,
message,
level: announcement.level,
relatedId: inserted.id,
relatedType: 'site_announcement',
createdAt: seenAt,
}).run();
result.events += 1;
await sendNotification(title, message, announcement.level);
result.notifications += 1;
}
} catch (error) {
result.failed += 1;
result.failedSites.push({
siteId: site.id,
siteName: site.name,
message: error instanceof Error && error.message ? error.message : 'unknown error',
});
}
}
return result;
}
+32
View File
@@ -57,6 +57,22 @@ describe('siteProxy', () => {
.toBeNull();
});
it('prefers site-specific proxy url over the shared system proxy', async () => {
await db.insert(schema.settings).values({
key: 'system_proxy_url',
value: JSON.stringify('http://127.0.0.1:7890'),
}).run();
await db.run(sql`
INSERT INTO sites (name, url, platform, proxy_url, use_system_proxy)
VALUES ('proxy-site', 'https://proxy-site.example.com', 'new-api', 'socks5://127.0.0.1:1080', 1)
`);
const { resolveSiteProxyUrlByRequestUrl } = await import('./siteProxy.js');
expect(await resolveSiteProxyUrlByRequestUrl('https://proxy-site.example.com/v1/models'))
.toBe('socks5://127.0.0.1:1080');
});
it('injects dispatcher when a site opts into the configured system proxy', async () => {
await db.insert(schema.settings).values({
key: 'system_proxy_url',
@@ -75,6 +91,20 @@ describe('siteProxy', () => {
expect('dispatcher' in requestInit).toBe(true);
});
it('injects dispatcher when a site defines its own proxy url', async () => {
await db.run(sql`
INSERT INTO sites (name, url, platform, proxy_url, use_system_proxy)
VALUES ('proxy-site', 'https://proxy-site.example.com', 'new-api', 'http://127.0.0.1:7890', 0)
`);
const { withSiteProxyRequestInit } = await import('./siteProxy.js');
const requestInit = await withSiteProxyRequestInit('https://proxy-site.example.com/v1/chat/completions', {
method: 'POST',
});
expect('dispatcher' in requestInit).toBe(true);
});
it('injects a working dispatcher for socks5 system proxies', async () => {
const upstreamServer = createServer((_request, response) => {
response.writeHead(200, { 'content-type': 'application/json' });
@@ -170,6 +200,7 @@ describe('siteProxy', () => {
it('merges site custom headers from site records even without cache lookup', async () => {
const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js');
const requestInit = withSiteRecordProxyRequestInit({
proxyUrl: 'http://127.0.0.1:7890',
useSystemProxy: false,
customHeaders: JSON.stringify({
'x-site-scope': 'site-level',
@@ -184,6 +215,7 @@ describe('siteProxy', () => {
expect(headers.get('x-site-scope')).toBe('site-level');
expect(headers.get('x-request-id')).toBe('req-1');
expect('dispatcher' in requestInit).toBe(true);
});
it('resolveChannelProxyUrl prefers account proxy over site proxy', async () => {
+8 -1
View File
@@ -33,6 +33,7 @@ const DEFAULT_PROXY_KEEPALIVE_INITIAL_DELAY_MS = 60_000;
type SiteProxyRow = {
siteUrl: string;
proxyUrl: string | null;
useSystemProxy: boolean;
customHeaders: string | null;
};
@@ -44,6 +45,7 @@ type ParsedSiteProxyInput = {
};
type SiteProxyConfigLike = {
proxyUrl?: string | null;
useSystemProxy?: boolean | null;
customHeaders?: string | null;
};
@@ -116,6 +118,7 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[
db
.select({
siteUrl: schema.sites.url,
proxyUrl: schema.sites.proxyUrl,
useSystemProxy: schema.sites.useSystemProxy,
customHeaders: schema.sites.customHeaders,
})
@@ -142,6 +145,7 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[
loadedAt: nowMs,
rows: rows.map((row) => ({
siteUrl: normalizeSiteUrl(row.siteUrl),
proxyUrl: normalizeSiteProxyUrl(row.proxyUrl),
useSystemProxy: !!row.useSystemProxy,
customHeaders: typeof row.customHeaders === 'string' ? row.customHeaders : null,
})),
@@ -385,7 +389,8 @@ async function resolveSiteRequestConfigByRequestUrl(requestUrl: string): Promise
const rows = await getCachedSiteProxyRows();
const matchedRow = findBestMatchingSiteRow(rows, normalizedRequestUrl);
const proxyUrl = matchedRow?.useSystemProxy ? siteProxyCache.systemProxyUrl : null;
const proxyUrl = matchedRow?.proxyUrl
|| (matchedRow?.useSystemProxy ? siteProxyCache.systemProxyUrl : null);
return {
proxyUrl: proxyUrl || null,
customHeaders: matchedRow?.customHeaders ?? null,
@@ -446,6 +451,8 @@ export function withExplicitProxyRequestInit(
}
export function resolveProxyUrlForSite(site: SiteProxyConfigLike | null | undefined): string | null {
const explicitProxyUrl = normalizeSiteProxyUrl(site?.proxyUrl);
if (explicitProxyUrl) return explicitProxyUrl;
if (!site?.useSystemProxy) return null;
return normalizeSiteProxyUrl(config.systemProxyUrl);
}
@@ -13,6 +13,7 @@ describe('TokenRouter runtime cache', () => {
let schema: DbModule['schema'];
let TokenRouter: TokenRouterModule['TokenRouter'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
let config: ConfigModule['config'];
let dataDir = '';
let originalCacheTtlMs = 0;
@@ -29,6 +30,7 @@ describe('TokenRouter runtime cache', () => {
schema = dbModule.schema;
TokenRouter = tokenRouterModule.TokenRouter;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
config = configModule.config;
originalCacheTtlMs = config.tokenRouterCacheTtlMs;
});
@@ -36,16 +38,19 @@ describe('TokenRouter runtime cache', () => {
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.settings).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
config.tokenRouterCacheTtlMs = 60_000;
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
});
afterAll(() => {
config.tokenRouterCacheTtlMs = originalCacheTtlMs;
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
delete process.env.DATA_DIR;
});
@@ -102,6 +102,47 @@ describe('TokenRouter downstream policy', () => {
expect(blockedPick).toBeNull();
});
it('rejects route selection when both supportedModels and allowedRouteIds are empty', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-deny-all',
url: 'https://deny-all.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'user-deny-all',
accessToken: 'access-deny-all',
apiToken: 'sk-deny-all',
status: 'active',
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: null,
priority: 0,
weight: 10,
enabled: true,
}).run();
const router = new TokenRouter();
const deniedPick = await router.selectChannel('gpt-4o-mini', {
allowedRouteIds: [],
supportedModels: [],
siteWeightMultipliers: {},
denyAllWhenEmpty: true,
});
expect(deniedPick).toBeNull();
});
it('applies site weight multipliers to probability explanation', async () => {
const siteHigh = await db.insert(schema.sites).values({
name: 'high-site',
@@ -93,6 +93,27 @@ describe('TokenRouter patterns and model mapping', () => {
return { route, channel };
}
async function createExplicitGroupRoute(
displayName: string,
sourceRouteIds: number[],
) {
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: displayName,
displayName,
routeMode: 'explicit_group',
enabled: true,
}).returning().get();
await db.insert(schema.routeGroupSources).values(
sourceRouteIds.map((sourceRouteId) => ({
groupRouteId: route.id,
sourceRouteId,
})),
).run();
return route;
}
it('matches routes with re: regex patterns', async () => {
await createRouteWithSingleChannel('re:^claude-(opus|sonnet)-4-6$');
const router = new TokenRouter();
@@ -181,4 +202,19 @@ describe('TokenRouter patterns and model mapping', () => {
expect(selected?.actualModel).toBe('claude-opus-4-6');
expect(decision.actualModel).toBe('claude-opus-4-6');
});
it('falls back to the source exact-route model when explicit-group channels omit sourceModel', async () => {
const source = await createRouteWithSingleChannel('claude-opus-4-5');
await createExplicitGroupRoute('claude-test-4.6-sonnet', [source.route.id]);
const router = new TokenRouter();
const selected = await router.selectChannel('claude-test-4.6-sonnet');
const decision = await router.explainSelection('claude-test-4.6-sonnet');
expect(selected).toBeTruthy();
expect(selected?.actualModel).toBe('claude-opus-4-5');
expect(decision.actualModel).toBe('claude-opus-4-5');
expect(decision.summary).toContain('按显示名命中:claude-test-4.6-sonnet');
expect(decision.summary).toContain('实际转发模型:claude-opus-4-5');
});
});
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../db/index.js');
type TokenRouterModule = typeof import('./tokenRouter.js');
@@ -24,6 +25,8 @@ describe('TokenRouter selection scoring', () => {
let schema: DbModule['schema'];
let TokenRouter: TokenRouterModule['TokenRouter'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
let flushSiteRuntimeHealthPersistence: TokenRouterModule['flushSiteRuntimeHealthPersistence'];
let config: ConfigModule['config'];
let dataDir = '';
let idSeed = 0;
@@ -47,6 +50,8 @@ describe('TokenRouter selection scoring', () => {
schema = dbModule.schema;
TokenRouter = tokenRouterModule.TokenRouter;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
flushSiteRuntimeHealthPersistence = tokenRouterModule.flushSiteRuntimeHealthPersistence;
config = configModule.config;
originalRoutingWeights = { ...config.routingWeights };
originalRoutingFallbackUnitCost = config.routingFallbackUnitCost;
@@ -58,16 +63,19 @@ describe('TokenRouter selection scoring', () => {
mockedCatalogRoutingCost.mockReturnValue(null);
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.settings).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
});
afterAll(() => {
config.routingWeights = { ...originalRoutingWeights };
config.routingFallbackUnitCost = originalRoutingFallbackUnitCost;
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
delete process.env.DATA_DIR;
});
@@ -398,4 +406,400 @@ describe('TokenRouter selection scoring', () => {
expect(catalogCandidate?.reason || '').toContain('成本=目录:0.200000');
expect(fallbackCandidate?.reason || '').toContain('成本=默认:100.000000');
});
it('downweights a site after transient failures and restores it quickly after success', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('gpt-5.4');
const siteA = await createSite('runtime-a');
const accountA = await createAccount(siteA.id, 'runtime-user-a');
const tokenA = await createToken(accountA.id, 'runtime-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('runtime-b');
const accountB = await createAccount(siteB.id, 'runtime-user-b');
const tokenB = await createToken(accountB.id, 'runtime-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
let decision = await router.explainSelection('gpt-5.4');
let candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
let candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(candidateA?.probability).toBeCloseTo(50, 1);
expect(candidateB?.probability).toBeCloseTo(50, 1);
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Bad gateway',
});
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
invalidateTokenRouterCache();
decision = await router.explainSelection('gpt-5.4');
candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(candidateA).toBeTruthy();
expect(candidateB).toBeTruthy();
expect((candidateA?.probability || 0)).toBeLessThan(30);
expect(candidateA?.reason || '').toContain('运行时健康=');
expect((candidateB?.probability || 0)).toBeGreaterThan(70);
await router.recordSuccess(channelA.id, 800, 0);
invalidateTokenRouterCache();
decision = await router.explainSelection('gpt-5.4');
candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect((candidateA?.probability || 0)).toBeGreaterThan(40);
expect((candidateB?.probability || 0)).toBeLessThan(60);
});
it('opens a site breaker after repeated transient failures and closes it after recovery', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('gpt-5.3');
const siteA = await createSite('breaker-a');
const accountA = await createAccount(siteA.id, 'breaker-user-a');
const tokenA = await createToken(accountA.id, 'breaker-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('breaker-b');
const accountB = await createAccount(siteB.id, 'breaker-user-b');
const tokenB = await createToken(accountB.id, 'breaker-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
for (let index = 0; index < 3; index += 1) {
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Gateway timeout',
});
}
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
invalidateTokenRouterCache();
let decision = await router.explainSelection('gpt-5.3');
const breakerCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
const breakerCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(breakerCandidateA?.reason || '').toContain('站点熔断');
expect((breakerCandidateA?.probability || 0)).toBe(0);
expect((breakerCandidateB?.probability || 0)).toBe(100);
expect(decision.summary.join(' ')).toContain('站点熔断避让');
await router.recordSuccess(channelA.id, 600, 0);
invalidateTokenRouterCache();
decision = await router.explainSelection('gpt-5.3');
const recoveredCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
const recoveredCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect((recoveredCandidateA?.probability || 0)).toBeGreaterThan(30);
expect((recoveredCandidateB?.probability || 0)).toBeLessThan(70);
});
it('uses persisted site success and latency history to prefer historically healthier sites', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('claude-4-sonnet');
const siteStable = await createSite('history-stable');
const accountStable = await createAccount(siteStable.id, 'history-user-stable');
const tokenStable = await createToken(accountStable.id, 'history-token-stable');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountStable.id,
tokenId: tokenStable.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 90,
failCount: 10,
totalLatencyMs: 90 * 240,
}).run();
const siteWeak = await createSite('history-weak');
const accountWeak = await createAccount(siteWeak.id, 'history-user-weak');
const tokenWeak = await createToken(accountWeak.id, 'history-token-weak');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountWeak.id,
tokenId: tokenWeak.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 20,
failCount: 30,
totalLatencyMs: 20 * 5200,
}).run();
const decision = await new TokenRouter().explainSelection('claude-4-sonnet');
const stableCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('history-stable'));
const weakCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('history-weak'));
expect(stableCandidate).toBeTruthy();
expect(weakCandidate).toBeTruthy();
expect((stableCandidate?.probability || 0)).toBeGreaterThan(weakCandidate?.probability || 0);
expect(stableCandidate?.reason || '').toContain('历史健康=');
expect(stableCandidate?.reason || '').toContain('成功率=90.0%');
expect(weakCandidate?.reason || '').toContain('成功率=40.0%');
});
it('reloads persisted runtime health after in-memory reset', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('gpt-4o-mini');
const siteA = await createSite('persist-a');
const accountA = await createAccount(siteA.id, 'persist-user-a');
const tokenA = await createToken(accountA.id, 'persist-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('persist-b');
const accountB = await createAccount(siteB.id, 'persist-user-b');
const tokenB = await createToken(accountB.id, 'persist-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Gateway timeout',
modelName: 'gpt-4o-mini',
});
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
await flushSiteRuntimeHealthPersistence();
const persisted = await db.select().from(schema.settings)
.where(eq(schema.settings.key, 'token_router_site_runtime_health_v1'))
.get();
expect(persisted?.value).toBeTruthy();
resetSiteRuntimeHealthState();
invalidateTokenRouterCache();
const decision = await new TokenRouter().explainSelection('gpt-4o-mini');
const candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
const candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(candidateA).toBeTruthy();
expect(candidateB).toBeTruthy();
expect((candidateA?.probability || 0)).toBeLessThan((candidateB?.probability || 0));
expect(candidateA?.reason || '').toContain('运行时健康=');
});
it('penalizes the failed model more than unrelated models on the same site', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const gptRoute = await createRoute('gpt-5.4');
const claudeRoute = await createRoute('claude-sonnet-4-6');
const siteA = await createSite('model-aware-a');
const accountA = await createAccount(siteA.id, 'model-aware-user-a');
const tokenA = await createToken(accountA.id, 'model-aware-token-a');
const gptChannelA = await db.insert(schema.routeChannels).values({
routeId: gptRoute.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values({
routeId: claudeRoute.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).run();
const siteB = await createSite('model-aware-b');
const accountB = await createAccount(siteB.id, 'model-aware-user-b');
const tokenB = await createToken(accountB.id, 'model-aware-token-b');
await db.insert(schema.routeChannels).values([
{
routeId: gptRoute.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
},
{
routeId: claudeRoute.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
},
]).run();
const router = new TokenRouter();
await router.recordFailure(gptChannelA.id, {
status: 502,
errorText: 'Bad gateway',
modelName: 'gpt-5.4',
});
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, gptChannelA.id)).run();
invalidateTokenRouterCache();
const gptDecision = await router.explainSelection('gpt-5.4');
const claudeDecision = await router.explainSelection('claude-sonnet-4-6');
const gptCandidateA = gptDecision.candidates.find((candidate) => candidate.siteName.startsWith('model-aware-a'));
const claudeCandidateA = claudeDecision.candidates.find((candidate) => candidate.siteName.startsWith('model-aware-a'));
expect(gptCandidateA).toBeTruthy();
expect(claudeCandidateA).toBeTruthy();
expect((gptCandidateA?.probability || 0)).toBeLessThan((claudeCandidateA?.probability || 0));
expect(gptCandidateA?.reason || '').toContain('模型=');
});
it('stable_first deterministically chooses the healthiest candidate', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.1',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const siteA = await createSite('stable-first-a');
const accountA = await createAccount(siteA.id, 'stable-first-user-a');
const tokenA = await createToken(accountA.id, 'stable-first-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('stable-first-b');
const accountB = await createAccount(siteB.id, 'stable-first-user-b');
const tokenB = await createToken(accountB.id, 'stable-first-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Gateway timeout',
modelName: 'gpt-5.1',
});
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
invalidateTokenRouterCache();
const preview = await router.previewSelectedChannel('gpt-5.1');
const decision = await router.explainSelection('gpt-5.1');
expect(preview?.channel.id).toBe(channelB.id);
expect(decision.summary.join(' ')).toContain('稳定优先');
expect(decision.selectedChannelId).toBe(channelB.id);
});
});
File diff suppressed because it is too large Load Diff