Add tests for accounts login shield and proxy log retention service

- Implement tests for accounts login shield detection, verifying behavior when login fails with HTML JSON parse syntax error.
- Create tests for proxy log retention service, ensuring expired logs are deleted based on retention days and that cleanup is skipped when retention is disabled.
- Add tests for token router cache, validating that route snapshots are maintained within TTL until explicitly invalidated.
- Introduce necessary mock setups and database seeding for comprehensive testing.
This commit is contained in:
cita
2026-03-04 14:59:06 +08:00
parent 536d957a9e
commit 82e9f3b601
18 changed files with 2687 additions and 1942 deletions
+3
View File
@@ -49,6 +49,9 @@ export const config = {
port: Math.trunc(parseNumber(process.env.PORT, 4000)), port: Math.trunc(parseNumber(process.env.PORT, 4000)),
dataDir: process.env.DATA_DIR || './data', dataDir: process.env.DATA_DIR || './data',
routingFallbackUnitCost: Math.max(1e-6, parseNumber(process.env.ROUTING_FALLBACK_UNIT_COST, 1)), routingFallbackUnitCost: Math.max(1e-6, parseNumber(process.env.ROUTING_FALLBACK_UNIT_COST, 1)),
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(process.env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(process.env.PROXY_LOG_RETENTION_DAYS, 30))),
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(process.env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
routingWeights: { routingWeights: {
baseWeightFactor: parseNumber(process.env.BASE_WEIGHT_FACTOR, 0.5), baseWeightFactor: parseNumber(process.env.BASE_WEIGHT_FACTOR, 0.5),
valueScoreFactor: parseNumber(process.env.VALUE_SCORE_FACTOR, 0.5), valueScoreFactor: parseNumber(process.env.VALUE_SCORE_FACTOR, 0.5),
+5
View File
@@ -19,6 +19,7 @@ import { monitorRoutes } from './routes/api/monitor.js';
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js'; import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
import { proxyRoutes } from './routes/proxy/router.js'; import { proxyRoutes } from './routes/proxy/router.js';
import { startScheduler } from './services/checkinScheduler.js'; import { startScheduler } from './services/checkinScheduler.js';
import { startProxyLogRetentionService, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
import { buildStartupSummaryLines } from './services/startupInfo.js'; import { buildStartupSummaryLines } from './services/startupInfo.js';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { normalize, resolve, sep } from 'path'; import { normalize, resolve, sep } from 'path';
@@ -195,6 +196,10 @@ if (existsSync(webDir)) {
// Start scheduler // Start scheduler
startScheduler(); startScheduler();
startProxyLogRetentionService();
app.addHook('onClose', async () => {
stopProxyLogRetentionService();
});
// Start server // Start server
try { try {
@@ -0,0 +1,85 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const loginMock = vi.fn();
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
login: (...args: unknown[]) => loginMock(...args),
}),
}));
type DbModule = typeof import('../../db/index.js');
describe('accounts login shield detection', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-login-shield-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accounts.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.accountsRoutes);
});
beforeEach(() => {
loginMock.mockReset();
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('returns shieldBlocked when login fails with html-json parse syntax error', async () => {
loginMock.mockResolvedValueOnce({
success: false,
message: "Unexpected token '<', \"<html><scr\"... is not valid JSON",
});
const site = db.insert(schema.sites).values({
name: 'AnyRouter',
url: 'https://anyrouter.example.com',
platform: 'new-api',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/accounts/login',
payload: {
siteId: site.id,
username: 'demo-user',
password: 'demo-password',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as { success?: boolean; shieldBlocked?: boolean; message?: string };
expect(body.success).toBe(false);
expect(body.shieldBlocked).toBe(true);
expect((body.message || '').toLowerCase()).toContain('shield');
expect(body.message || '').not.toContain('Unexpected token');
});
});
+44 -5
View File
@@ -119,6 +119,40 @@ function getNextAccountSortOrder(): number {
const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1); const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1);
return max + 1; return max + 1;
} }
type LoginFailureInfo = {
message: string;
shieldBlocked: boolean;
};
function normalizeLoginFailure(message: string | null | undefined): LoginFailureInfo {
const raw = (message || '').trim();
const lowered = raw.toLowerCase();
const looksLikeHtmlJsonParseError = (
lowered.includes('unexpected token')
&& lowered.includes('not valid json')
&& (lowered.includes('<html') || lowered.includes('<script'))
);
const looksLikeShieldChallenge = (
lowered.includes('acw_sc__v2')
|| lowered.includes('var arg1')
|| lowered.includes('captcha')
|| lowered.includes('challenge')
|| lowered.includes('cloudflare tunnel error')
);
if (looksLikeHtmlJsonParseError || looksLikeShieldChallenge) {
return {
shieldBlocked: true,
message: 'This site is shielded by anti-bot challenge. Account/password login is blocked. Create an API key on the target site and import that key.',
};
}
return {
shieldBlocked: false,
message: raw || 'login failed',
};
}
function summarizeAccountHealthRefresh(results: AccountHealthRefreshResult[]) { function summarizeAccountHealthRefresh(results: AccountHealthRefreshResult[]) {
return { return {
@@ -296,11 +330,16 @@ export async function accountsRoutes(app: FastifyInstance) {
const adapter = getAdapter(site.platform); const adapter = getAdapter(site.platform);
if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` }; if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
// Login to the target site // Login to the target site
const loginResult = await adapter.login(site.url, username, password); const loginResult = await adapter.login(site.url, username, password);
if (!loginResult.success || !loginResult.accessToken) { if (!loginResult.success || !loginResult.accessToken) {
return { success: false, message: loginResult.message || 'login failed' }; const normalizedFailure = normalizeLoginFailure(loginResult.message);
} return {
success: false,
shieldBlocked: normalizedFailure.shieldBlocked,
message: normalizedFailure.message,
};
}
const guessedPlatformUserId = guessPlatformUserIdFromUsername(username); const guessedPlatformUserId = guessPlatformUserIdFromUsername(username);
+76 -66
View File
@@ -1,6 +1,6 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js'; import { db, schema } from '../../db/index.js';
import { and, desc, gte, eq, lt } from 'drizzle-orm'; import { and, desc, gte, eq, lt, sql } from 'drizzle-orm';
import { import {
refreshModelsForAccount, refreshModelsForAccount,
refreshModelsAndRebuildRoutes, refreshModelsAndRebuildRoutes,
@@ -13,9 +13,9 @@ import { getRunningTaskByDedupeKey, startBackgroundTask } from '../../services/b
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js'; import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js'; import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js';
import { import {
formatUtcSqlDateTime,
getLocalDayRangeUtc, getLocalDayRangeUtc,
getLocalRangeStartUtc, getLocalRangeStartUtc,
parseStoredUtcDateTime,
toLocalDayKeyFromStoredUtc, toLocalDayKeyFromStoredUtc,
} from '../../services/localTimeService.js'; } from '../../services/localTimeService.js';
@@ -55,6 +55,19 @@ function writeModelsMarketplaceCache(includePricing: boolean, models: any[]): vo
}); });
} }
function proxyCostSqlExpression() {
return sql<number>`
coalesce(
${schema.proxyLogs.estimatedCost},
case
when lower(coalesce(${schema.sites.platform}, 'new-api')) = 'veloera'
then coalesce(${schema.proxyLogs.totalTokens}, 0) / 1000000.0
else coalesce(${schema.proxyLogs.totalTokens}, 0) / 500000.0
end
)
`;
}
export async function statsRoutes(app: FastifyInstance) { export async function statsRoutes(app: FastifyInstance) {
// Dashboard summary // Dashboard summary
app.get('/api/stats/dashboard', async () => { app.get('/api/stats/dashboard', async () => {
@@ -94,7 +107,7 @@ export async function statsRoutes(app: FastifyInstance) {
} }
const nowTs = Date.now(); const nowTs = Date.now();
const last24hTs = nowTs - 86400000; const last24hDate = formatUtcSqlDateTime(new Date(nowTs - 86400000));
const last7dDate = getLocalRangeStartUtc(7); const last7dDate = getLocalRangeStartUtc(7);
const recentProxyLogs = db.select().from(schema.proxyLogs) const recentProxyLogs = db.select().from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
@@ -102,36 +115,44 @@ export async function statsRoutes(app: FastifyInstance) {
.where(and(gte(schema.proxyLogs.createdAt, last7dDate), eq(schema.sites.status, 'active'))) .where(and(gte(schema.proxyLogs.createdAt, last7dDate), eq(schema.sites.status, 'active')))
.all() .all()
.map((row) => row.proxy_logs); .map((row) => row.proxy_logs);
const allProxyLogs = db.select() const totalUsedRow = db.select({
totalUsed: sql<number>`coalesce(sum(${proxyCostSqlExpression()}), 0)`,
})
.from(schema.proxyLogs) .from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.sites.status, 'active')) .where(eq(schema.sites.status, 'active'))
.all(); .get();
const proxy24hRow = db.select({
total: sql<number>`count(*)`,
success: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
failed: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'failed' then 1 else 0 end), 0)`,
totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
})
.from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(gte(schema.proxyLogs.createdAt, last24hDate), eq(schema.sites.status, 'active')))
.get();
const todaySpendRow = db.select({
todaySpend: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
})
.from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(
gte(schema.proxyLogs.createdAt, todayStartUtc),
lt(schema.proxyLogs.createdAt, todayEndUtc),
eq(schema.sites.status, 'active'),
))
.get();
const proxy24hLogs = recentProxyLogs.filter((log) => { const proxySuccess = Number(proxy24hRow?.success || 0);
if (!log.createdAt) return false; const proxyFailed = Number(proxy24hRow?.failed || 0);
const ts = parseStoredUtcDateTime(log.createdAt)?.getTime() ?? Number.NaN; const proxyTotal = Number(proxy24hRow?.total || 0);
return Number.isFinite(ts) && ts >= last24hTs; const totalTokens = Number(proxy24hRow?.totalTokens || 0);
}); const totalUsed = Number(totalUsedRow?.totalUsed || 0);
const proxySuccess = proxy24hLogs.filter((l) => l.status === 'success').length; const todaySpend = Number(todaySpendRow?.todaySpend || 0);
const proxyFailed = proxy24hLogs.filter((l) => l.status === 'failed').length;
const totalTokens = proxy24hLogs.reduce((sum, l) => sum + (l.totalTokens || 0), 0);
const totalUsed = allProxyLogs.reduce((sum, row) => {
const log = row.proxy_logs;
const platform = row.sites?.platform || 'new-api';
const explicitCost = typeof log.estimatedCost === 'number' ? log.estimatedCost : 0;
if (explicitCost > 0) return sum + explicitCost;
return sum + fallbackTokenCost(log.totalTokens || 0, platform);
}, 0);
const todayProxyLogs = recentProxyLogs.filter((log) => {
if (!log.createdAt) return false;
return log.createdAt >= todayStartUtc && log.createdAt < todayEndUtc;
});
const todaySpend = todayProxyLogs.reduce((sum, log) => {
const cost = typeof log.estimatedCost === 'number' ? log.estimatedCost : 0;
return sum + cost;
}, 0);
const todayReward = accounts.reduce((sum, account) => sum + estimateRewardWithTodayIncomeFallback({ const todayReward = accounts.reduce((sum, account) => sum + estimateRewardWithTodayIncomeFallback({
day: today, day: today,
successCount: successCountByAccount[account.id] || 0, successCount: successCountByAccount[account.id] || 0,
@@ -149,7 +170,7 @@ export async function statsRoutes(app: FastifyInstance) {
activeAccounts: activeCount, activeAccounts: activeCount,
totalAccounts: accounts.length, totalAccounts: accounts.length,
todayCheckin: { success: checkinSuccess, failed: checkinFailed, total: todayCheckins.length }, todayCheckin: { success: checkinSuccess, failed: checkinFailed, total: todayCheckins.length },
proxy24h: { success: proxySuccess, failed: proxyFailed, total: proxy24hLogs.length, totalTokens }, proxy24h: { success: proxySuccess, failed: proxyFailed, total: proxyTotal, totalTokens },
modelAnalysis, modelAnalysis,
}; };
}); });
@@ -728,54 +749,43 @@ export async function statsRoutes(app: FastifyInstance) {
// Site distribution – per-site aggregate data // Site distribution – per-site aggregate data
app.get('/api/stats/site-distribution', async () => { app.get('/api/stats/site-distribution', async () => {
const accounts = db.select().from(schema.accounts) const accountRows = db.select({
siteId: schema.sites.id,
siteName: schema.sites.name,
platform: schema.sites.platform,
totalBalance: sql<number>`coalesce(sum(coalesce(${schema.accounts.balance}, 0)), 0)`,
accountCount: sql<number>`count(*)`,
})
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.sites.status, 'active')) .where(eq(schema.sites.status, 'active'))
.groupBy(schema.sites.id, schema.sites.name, schema.sites.platform)
.all(); .all();
const proxyLogs = db.select().from(schema.proxyLogs) const spendRows = db.select({
siteId: schema.sites.id,
totalSpend: sql<number>`coalesce(sum(${proxyCostSqlExpression()}), 0)`,
})
.from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.sites.status, 'active')) .where(eq(schema.sites.status, 'active'))
.groupBy(schema.sites.id)
.all(); .all();
// Build spend per site from proxy logs const spendBySiteId = new Map<number, number>();
const spendBySiteId: Record<number, number> = {}; for (const row of spendRows) {
for (const row of proxyLogs) { if (row.siteId == null) continue;
const siteId = row.sites?.id; spendBySiteId.set(row.siteId, Number(row.totalSpend || 0));
if (siteId == null) continue;
const log = row.proxy_logs;
const platform = row.sites?.platform || 'new-api';
const explicitCost = typeof log.estimatedCost === 'number' ? log.estimatedCost : 0;
const cost = explicitCost > 0 ? explicitCost : fallbackTokenCost(log.totalTokens || 0, platform);
spendBySiteId[siteId] = (spendBySiteId[siteId] || 0) + cost;
} }
// Aggregate accounts by site const distribution = accountRows.map((row) => ({
const siteMap: Record<number, { siteId: row.siteId,
siteName: string; siteName: row.siteName,
platform: string; platform: row.platform,
totalBalance: number; totalBalance: Math.round(Number(row.totalBalance || 0) * 1_000_000) / 1_000_000,
accountCount: number; totalSpend: Math.round((spendBySiteId.get(row.siteId) || 0) * 1_000_000) / 1_000_000,
}> = {}; accountCount: Number(row.accountCount || 0),
for (const row of accounts) {
const site = row.sites;
const acct = row.accounts;
if (!siteMap[site.id]) {
siteMap[site.id] = { siteName: site.name, platform: site.platform, totalBalance: 0, accountCount: 0 };
}
siteMap[site.id].totalBalance += acct.balance || 0;
siteMap[site.id].accountCount++;
}
const distribution = Object.entries(siteMap).map(([id, info]) => ({
siteId: Number(id),
siteName: info.siteName,
platform: info.platform,
totalBalance: Math.round(info.totalBalance * 1_000_000) / 1_000_000,
totalSpend: Math.round((spendBySiteId[Number(id)] || 0) * 1_000_000) / 1_000_000,
accountCount: info.accountCount,
})); }));
return { distribution }; return { distribution };
@@ -5,11 +5,13 @@ import { join } from 'node:path';
import { mkdtempSync } from 'node:fs'; import { mkdtempSync } from 'node:fs';
type DbModule = typeof import('../../db/index.js'); type DbModule = typeof import('../../db/index.js');
type TokenRouterModule = typeof import('../../services/tokenRouter.js');
describe('POST /api/routes/decision/batch', () => { describe('POST /api/routes/decision/batch', () => {
let app: FastifyInstance; let app: FastifyInstance;
let db: DbModule['db']; let db: DbModule['db'];
let schema: DbModule['schema']; let schema: DbModule['schema'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let dataDir = ''; let dataDir = '';
let seedId = 0; let seedId = 0;
@@ -56,8 +58,10 @@ describe('POST /api/routes/decision/batch', () => {
await import('../../db/migrate.js'); await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js'); const dbModule = await import('../../db/index.js');
const routesModule = await import('./tokens.js'); const routesModule = await import('./tokens.js');
const tokenRouterModule = await import('../../services/tokenRouter.js');
db = dbModule.db; db = dbModule.db;
schema = dbModule.schema; schema = dbModule.schema;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
app = Fastify(); app = Fastify();
await app.register(routesModule.tokensRoutes); await app.register(routesModule.tokensRoutes);
@@ -69,10 +73,12 @@ describe('POST /api/routes/decision/batch', () => {
db.delete(schema.tokenRoutes).run(); db.delete(schema.tokenRoutes).run();
db.delete(schema.accounts).run(); db.delete(schema.accounts).run();
db.delete(schema.sites).run(); db.delete(schema.sites).run();
invalidateTokenRouterCache();
}); });
afterAll(async () => { afterAll(async () => {
await app.close(); await app.close();
invalidateTokenRouterCache();
delete process.env.DATA_DIR; delete process.env.DATA_DIR;
}); });
File diff suppressed because it is too large Load Diff
+352 -349
View File
@@ -1,350 +1,353 @@
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js'; import { db, schema } from '../db/index.js';
import { getAdapter } from './platforms/index.js'; import { getAdapter } from './platforms/index.js';
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js'; import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js';
import { resolvePlatformUserId } from './accountExtraConfig.js'; import { resolvePlatformUserId } from './accountExtraConfig.js';
import { invalidateTokenRouterCache } from './tokenRouter.js';
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000; const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
const MODEL_REFRESH_BATCH_SIZE = 3; const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
const MODEL_REFRESH_BATCH_SIZE = 3;
function isSiteDisabled(status?: string | null): boolean {
return (status || 'active') === 'disabled'; function isSiteDisabled(status?: string | null): boolean {
} return (status || 'active') === 'disabled';
}
function normalizeModels(models: string[]): string[] {
return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0))); function normalizeModels(models: string[]): string[] {
} return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0)));
}
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim(); function isExactModelPattern(modelPattern: string): boolean {
if (!normalized) return false; const normalized = modelPattern.trim();
if (normalized.toLowerCase().startsWith('re:')) return false; if (!normalized) return false;
return !/[\*\?\[]/.test(normalized); if (normalized.toLowerCase().startsWith('re:')) return false;
} return !/[\*\?\[]/.test(normalized);
}
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null; async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
try { let timer: ReturnType<typeof setTimeout> | null = null;
return await Promise.race([ try {
fn(), return await Promise.race([
new Promise<T>((_, reject) => { fn(),
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); new Promise<T>((_, reject) => {
}), timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
]); }),
} finally { ]);
if (timer) clearTimeout(timer); } finally {
} if (timer) clearTimeout(timer);
} }
}
export async function refreshModelsForAccount(accountId: number) {
const row = db.select().from(schema.accounts) export async function refreshModelsForAccount(accountId: number) {
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) const row = db.select().from(schema.accounts)
.where(eq(schema.accounts.id, accountId)) .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.get(); .where(eq(schema.accounts.id, accountId))
.get();
if (!row) {
return { accountId, refreshed: false, modelCount: 0, reason: 'account_not_found' }; if (!row) {
} return { accountId, refreshed: false, modelCount: 0, reason: 'account_not_found' };
}
const account = row.accounts;
const site = row.sites; const account = row.accounts;
const adapter = getAdapter(site.platform); const site = row.sites;
const adapter = getAdapter(site.platform);
const accountTokens = db.select()
.from(schema.accountTokens) const accountTokens = db.select()
.where(eq(schema.accountTokens.accountId, accountId)) .from(schema.accountTokens)
.all(); .where(eq(schema.accountTokens.accountId, accountId))
.all();
db.delete(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, accountId)) db.delete(schema.modelAvailability)
.run(); .where(eq(schema.modelAvailability.accountId, accountId))
.run();
for (const token of accountTokens) {
db.delete(schema.tokenModelAvailability) for (const token of accountTokens) {
.where(eq(schema.tokenModelAvailability.tokenId, token.id)) db.delete(schema.tokenModelAvailability)
.run(); .where(eq(schema.tokenModelAvailability.tokenId, token.id))
} .run();
}
if (isSiteDisabled(site.status)) {
return { accountId, refreshed: false, modelCount: 0, reason: 'site_disabled' }; if (isSiteDisabled(site.status)) {
} return { accountId, refreshed: false, modelCount: 0, reason: 'site_disabled' };
}
if (!adapter || account.status !== 'active') {
return { accountId, refreshed: false, modelCount: 0, reason: 'adapter_or_status' }; if (!adapter || account.status !== 'active') {
} return { accountId, refreshed: false, modelCount: 0, reason: 'adapter_or_status' };
}
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
let discoveredApiToken: string | null = null; const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
let discoveredApiToken: string | null = null;
if (!account.apiToken && account.accessToken) {
try { if (!account.apiToken && account.accessToken) {
discoveredApiToken = await withTimeout( try {
() => adapter.getApiToken(site.url, account.accessToken, platformUserId), discoveredApiToken = await withTimeout(
API_TOKEN_DISCOVERY_TIMEOUT_MS, () => adapter.getApiToken(site.url, account.accessToken, platformUserId),
`api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`, API_TOKEN_DISCOVERY_TIMEOUT_MS,
); `api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
if (discoveredApiToken) { );
ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' }); if (discoveredApiToken) {
db.update(schema.accounts).set({ ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' });
apiToken: discoveredApiToken, db.update(schema.accounts).set({
updatedAt: new Date().toISOString(), apiToken: discoveredApiToken,
}).where(eq(schema.accounts.id, account.id)).run(); updatedAt: new Date().toISOString(),
} }).where(eq(schema.accounts.id, account.id)).run();
} catch {} }
} } catch {}
}
let enabledTokens = db.select()
.from(schema.accountTokens) let enabledTokens = db.select()
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true))) .from(schema.accountTokens)
.all(); .where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.all();
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
if (enabledTokens.length === 0) { // Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
const fallback = discoveredApiToken || account.apiToken || null; if (enabledTokens.length === 0) {
if (fallback) { const fallback = discoveredApiToken || account.apiToken || null;
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' }); if (fallback) {
enabledTokens = db.select() ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
.from(schema.accountTokens) enabledTokens = db.select()
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true))) .from(schema.accountTokens)
.all(); .where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
} .all();
} }
}
const accountModels = new Set<string>();
const modelLatency = new Map<string, number | null>(); const accountModels = new Set<string>();
let scannedTokenCount = 0; const modelLatency = new Map<string, number | null>();
let discoveredByCredential = false; let scannedTokenCount = 0;
const attemptedCredentials = new Set<string>(); let discoveredByCredential = false;
const attemptedCredentials = new Set<string>();
const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => {
for (const modelName of models) { const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => {
accountModels.add(modelName); for (const modelName of models) {
const prev = modelLatency.get(modelName); accountModels.add(modelName);
if (prev === undefined || prev === null) { const prev = modelLatency.get(modelName);
modelLatency.set(modelName, latencyMs); if (prev === undefined || prev === null) {
continue; modelLatency.set(modelName, latencyMs);
} continue;
if (latencyMs === null) continue; }
if (latencyMs < prev) modelLatency.set(modelName, latencyMs); if (latencyMs === null) continue;
} if (latencyMs < prev) modelLatency.set(modelName, latencyMs);
}; }
};
const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => {
const credential = (credentialRaw || '').trim(); const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => {
if (!credential) return; const credential = (credentialRaw || '').trim();
if (attemptedCredentials.has(credential)) return; if (!credential) return;
attemptedCredentials.add(credential); if (attemptedCredentials.has(credential)) return;
attemptedCredentials.add(credential);
const startedAt = Date.now();
let models: string[] = []; const startedAt = Date.now();
try { let models: string[] = [];
models = normalizeModels( try {
await withTimeout( models = normalizeModels(
() => adapter.getModels(site.url, credential, platformUserId), await withTimeout(
MODEL_DISCOVERY_TIMEOUT_MS, () => adapter.getModels(site.url, credential, platformUserId),
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, MODEL_DISCOVERY_TIMEOUT_MS,
), `model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
); ),
} catch { );
models = []; } catch {
} models = [];
if (models.length === 0) return; }
discoveredByCredential = true; if (models.length === 0) return;
const latencyMs = Date.now() - startedAt; discoveredByCredential = true;
mergeDiscoveredModels(models, latencyMs); const latencyMs = Date.now() - startedAt;
}; mergeDiscoveredModels(models, latencyMs);
};
// Prefer account-level credential discovery so model availability does not rely on managed tokens.
await discoverModelsWithCredential(account.apiToken); // Prefer account-level credential discovery so model availability does not rely on managed tokens.
await discoverModelsWithCredential(discoveredApiToken); await discoverModelsWithCredential(account.apiToken);
await discoverModelsWithCredential(account.accessToken); await discoverModelsWithCredential(discoveredApiToken);
await discoverModelsWithCredential(account.accessToken);
for (const token of enabledTokens) {
const startedAt = Date.now(); for (const token of enabledTokens) {
let models: string[] = []; const startedAt = Date.now();
let models: string[] = [];
try {
models = normalizeModels( try {
await withTimeout( models = normalizeModels(
() => adapter.getModels(site.url, token.token, platformUserId), await withTimeout(
MODEL_DISCOVERY_TIMEOUT_MS, () => adapter.getModels(site.url, token.token, platformUserId),
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, MODEL_DISCOVERY_TIMEOUT_MS,
), `model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
); ),
} catch { );
models = []; } catch {
} models = [];
}
if (models.length === 0) continue;
if (models.length === 0) continue;
const latencyMs = Date.now() - startedAt;
const checkedAt = new Date().toISOString(); const latencyMs = Date.now() - startedAt;
const checkedAt = new Date().toISOString();
db.insert(schema.tokenModelAvailability).values(
models.map((modelName) => ({ db.insert(schema.tokenModelAvailability).values(
tokenId: token.id, models.map((modelName) => ({
modelName, tokenId: token.id,
available: true, modelName,
latencyMs, available: true,
checkedAt, latencyMs,
})), checkedAt,
).run(); })),
).run();
scannedTokenCount++;
mergeDiscoveredModels(models, latencyMs); scannedTokenCount++;
} mergeDiscoveredModels(models, latencyMs);
}
if (accountModels.size > 0) {
const checkedAt = new Date().toISOString(); if (accountModels.size > 0) {
db.insert(schema.modelAvailability).values( const checkedAt = new Date().toISOString();
Array.from(accountModels).map((modelName) => ({ db.insert(schema.modelAvailability).values(
accountId: account.id, Array.from(accountModels).map((modelName) => ({
modelName, accountId: account.id,
available: true, modelName,
latencyMs: modelLatency.get(modelName) ?? null, available: true,
checkedAt, latencyMs: modelLatency.get(modelName) ?? null,
})), checkedAt,
).run(); })),
} ).run();
}
return {
accountId, return {
refreshed: true, accountId,
modelCount: accountModels.size, refreshed: true,
tokenScanned: scannedTokenCount, modelCount: accountModels.size,
discoveredByCredential, tokenScanned: scannedTokenCount,
discoveredApiToken: !!discoveredApiToken, discoveredByCredential,
}; discoveredApiToken: !!discoveredApiToken,
} };
}
async function refreshModelsForAllActiveAccounts() {
const accounts = db.select({ id: schema.accounts.id }).from(schema.accounts) async function refreshModelsForAllActiveAccounts() {
.where(eq(schema.accounts.status, 'active')) const accounts = db.select({ id: schema.accounts.id }).from(schema.accounts)
.all(); .where(eq(schema.accounts.status, 'active'))
.all();
const results = [];
for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) { const results = [];
const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE); for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) {
const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id))); const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE);
results.push(...batchResults); const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id)));
} results.push(...batchResults);
return results; }
} return results;
}
export function rebuildTokenRoutesFromAvailability() {
const tokenRows = db.select().from(schema.tokenModelAvailability) export function rebuildTokenRoutesFromAvailability() {
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id)) const tokenRows = db.select().from(schema.tokenModelAvailability)
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id)) .innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) .innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.where( .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
and( .where(
eq(schema.tokenModelAvailability.available, true), and(
eq(schema.accountTokens.enabled, true), eq(schema.tokenModelAvailability.available, true),
eq(schema.accounts.status, 'active'), eq(schema.accountTokens.enabled, true),
eq(schema.sites.status, 'active'), eq(schema.accounts.status, 'active'),
), eq(schema.sites.status, 'active'),
) ),
.all(); )
.all();
const modelTokens = new Map<string, Map<number, number>>();
for (const row of tokenRows) { const modelTokens = new Map<string, Map<number, number>>();
const modelName = row.token_model_availability.modelName; for (const row of tokenRows) {
if (!modelTokens.has(modelName)) modelTokens.set(modelName, new Map<number, number>()); const modelName = row.token_model_availability.modelName;
modelTokens.get(modelName)!.set(row.account_tokens.id, row.accounts.id); if (!modelTokens.has(modelName)) modelTokens.set(modelName, new Map<number, number>());
} modelTokens.get(modelName)!.set(row.account_tokens.id, row.accounts.id);
}
const routes = db.select().from(schema.tokenRoutes).all();
const channels = db.select().from(schema.routeChannels).all(); const routes = db.select().from(schema.tokenRoutes).all();
const channels = db.select().from(schema.routeChannels).all();
let createdRoutes = 0;
let createdChannels = 0; let createdRoutes = 0;
let removedChannels = 0; let createdChannels = 0;
let removedRoutes = 0; let removedChannels = 0;
let removedRoutes = 0;
for (const [modelName, tokenAccountMap] of modelTokens.entries()) {
let route = routes.find((r) => r.modelPattern === modelName); for (const [modelName, tokenAccountMap] of modelTokens.entries()) {
if (!route) { let route = routes.find((r) => r.modelPattern === modelName);
route = db.insert(schema.tokenRoutes).values({ if (!route) {
modelPattern: modelName, route = db.insert(schema.tokenRoutes).values({
enabled: true, modelPattern: modelName,
}).returning().get(); enabled: true,
routes.push(route); }).returning().get();
createdRoutes++; routes.push(route);
} createdRoutes++;
}
const routeChannels = channels.filter((channel) => channel.routeId === route.id);
const desiredTokenIds = new Set<number>(Array.from(tokenAccountMap.keys())); const routeChannels = channels.filter((channel) => channel.routeId === route.id);
const desiredTokenIds = new Set<number>(Array.from(tokenAccountMap.keys()));
for (const [tokenId, accountId] of tokenAccountMap.entries()) {
const exists = routeChannels.some((channel) => channel.tokenId === tokenId); for (const [tokenId, accountId] of tokenAccountMap.entries()) {
if (exists) continue; const exists = routeChannels.some((channel) => channel.tokenId === tokenId);
if (exists) continue;
const created = db.insert(schema.routeChannels).values({
routeId: route.id, const created = db.insert(schema.routeChannels).values({
accountId, routeId: route.id,
tokenId, accountId,
priority: 0, tokenId,
weight: 10, priority: 0,
enabled: true, weight: 10,
manualOverride: false, enabled: true,
}).returning().get(); manualOverride: false,
channels.push(created); }).returning().get();
createdChannels++; channels.push(created);
} createdChannels++;
}
for (const channel of routeChannels) {
if (channel.tokenId && desiredTokenIds.has(channel.tokenId)) { for (const channel of routeChannels) {
continue; if (channel.tokenId && desiredTokenIds.has(channel.tokenId)) {
} continue;
}
if (!channel.tokenId) {
const preferred = getPreferredAccountToken(channel.accountId); if (!channel.tokenId) {
if (preferred && desiredTokenIds.has(preferred.id)) { const preferred = getPreferredAccountToken(channel.accountId);
db.update(schema.routeChannels) if (preferred && desiredTokenIds.has(preferred.id)) {
.set({ tokenId: preferred.id }) db.update(schema.routeChannels)
.where(eq(schema.routeChannels.id, channel.id)) .set({ tokenId: preferred.id })
.run(); .where(eq(schema.routeChannels.id, channel.id))
continue; .run();
} continue;
} }
}
if (!channel.manualOverride) {
db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run(); if (!channel.manualOverride) {
removedChannels++; db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run();
} removedChannels++;
} }
} }
}
const latestModelNames = new Set<string>(Array.from(modelTokens.keys()));
for (const route of routes) { const latestModelNames = new Set<string>(Array.from(modelTokens.keys()));
const modelPattern = (route.modelPattern || '').trim(); for (const route of routes) {
if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) { const modelPattern = (route.modelPattern || '').trim();
continue; if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) {
} continue;
}
const routeChannelCount = channels.filter((channel) => channel.routeId === route.id).length;
if (routeChannelCount > 0) { const routeChannelCount = channels.filter((channel) => channel.routeId === route.id).length;
removedChannels += routeChannelCount; if (routeChannelCount > 0) {
} removedChannels += routeChannelCount;
}
const deleted = db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run().changes;
if (deleted > 0) { const deleted = db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run().changes;
removedRoutes += deleted; if (deleted > 0) {
} removedRoutes += deleted;
} }
}
return {
models: modelTokens.size, invalidateTokenRouterCache();
createdRoutes,
createdChannels, return {
removedChannels, models: modelTokens.size,
removedRoutes, createdRoutes,
}; createdChannels,
} removedChannels,
removedRoutes,
export async function refreshModelsAndRebuildRoutes() { };
const refresh = await refreshModelsForAllActiveAccounts(); }
const rebuild = rebuildTokenRoutesFromAvailability();
return { refresh, rebuild }; export async function refreshModelsAndRebuildRoutes() {
const refresh = await refreshModelsForAllActiveAccounts();
const rebuild = rebuildTokenRoutesFromAvailability();
return { refresh, rebuild };
} }
@@ -16,6 +16,13 @@ const CHECKIN_ALREADY_TOKEN = 'checkin-already-token';
const CHECKIN_INVALID_URL_TOKEN = 'checkin-invalid-url-token'; const CHECKIN_INVALID_URL_TOKEN = 'checkin-invalid-url-token';
const CHECKIN_CLOUDFLARE_530_TOKEN = 'checkin-cloudflare-530-token'; const CHECKIN_CLOUDFLARE_530_TOKEN = 'checkin-cloudflare-530-token';
const BALANCE_FAIL_TOKEN = 'balance-fail-token'; const BALANCE_FAIL_TOKEN = 'balance-fail-token';
const SHIELD_LOGIN_USERNAME = 'shield-user';
const SHIELD_LOGIN_PASSWORD = 'shield-pass';
const SHIELD_LOGIN_TOKEN = 'login-session-token';
const SHIELD_LOGIN_COOKIE = 'challenge-seed';
const COOKIE_ONLY_LOGIN_USERNAME = 'cookie-only-user';
const COOKIE_ONLY_LOGIN_PASSWORD = 'cookie-only-pass';
const COOKIE_ONLY_LOGIN_SESSION = 'cookie-only-session';
const COOKIE_SHIELDED_TOKEN = Buffer.from( const COOKIE_SHIELDED_TOKEN = Buffer.from(
`1771864970|${Buffer.from('username=linuxdo_131936').toString('base64')}|sig`, `1771864970|${Buffer.from('username=linuxdo_131936').toString('base64')}|sig`,
).toString('base64'); ).toString('base64');
@@ -57,6 +64,66 @@ describe('NewApiAdapter', () => {
return; return;
} }
if (req.url === '/api/user/login' && req.method === 'POST') {
let bodyRaw = '';
req.on('data', (chunk) => {
bodyRaw += chunk.toString();
});
req.on('end', () => {
let payload: Record<string, unknown> = {};
try {
payload = JSON.parse(bodyRaw || '{}');
} catch {}
const isShieldLogin =
payload.username === SHIELD_LOGIN_USERNAME &&
payload.password === SHIELD_LOGIN_PASSWORD;
const isCookieOnlyLogin =
payload.username === COOKIE_ONLY_LOGIN_USERNAME &&
payload.password === COOKIE_ONLY_LOGIN_PASSWORD;
if (!isShieldLogin && !isCookieOnlyLogin) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'invalid credentials' }));
return;
}
const cookieHeader = typeof req.headers.cookie === 'string' ? req.headers.cookie : '';
if (!cookieHeader.includes(`acw_sc__v2=${ANYROUTER_CHALLENGE_ACW}`)) {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Set-Cookie': `cdn_sec_tc=${SHIELD_LOGIN_COOKIE}; Path=/; HttpOnly`,
});
res.end(ANYROUTER_CHALLENGE_HTML);
return;
}
if (!cookieHeader.includes(`cdn_sec_tc=${SHIELD_LOGIN_COOKIE}`)) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'missing shield cookie' }));
return;
}
if (isCookieOnlyLogin) {
res.writeHead(200, {
'Content-Type': 'application/json',
'Set-Cookie': `session=${COOKIE_ONLY_LOGIN_SESSION}; Path=/; HttpOnly`,
});
res.end(JSON.stringify({
success: true,
data: {},
}));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
data: { token: SHIELD_LOGIN_TOKEN },
}));
});
return;
}
if (req.url === '/api/user/models') { if (req.url === '/api/user/models') {
if (req.headers['new-api-user'] !== '11494') { if (req.headers['new-api-user'] !== '11494') {
res.writeHead(401, { 'Content-Type': 'application/json' }); res.writeHead(401, { 'Content-Type': 'application/json' });
@@ -322,6 +389,40 @@ describe('NewApiAdapter', () => {
expect(token).toBe('api-key-from-token-list'); expect(token).toBe('api-key-from-token-list');
}); });
it('solves anyrouter acw challenge for account-password login', async () => {
const adapter = new NewApiAdapter();
const result = await adapter.login(baseUrl, SHIELD_LOGIN_USERNAME, SHIELD_LOGIN_PASSWORD);
expect(result.success).toBe(true);
expect(result.accessToken).toBe(SHIELD_LOGIN_TOKEN);
expect(
requests.some(
(r) =>
r.url === '/api/user/login' &&
typeof r.headers.cookie === 'string' &&
r.headers.cookie.includes(`acw_sc__v2=${ANYROUTER_CHALLENGE_ACW}`),
),
).toBe(true);
expect(
requests.some(
(r) =>
r.url === '/api/user/login' &&
typeof r.headers.cookie === 'string' &&
r.headers.cookie.includes(`cdn_sec_tc=${SHIELD_LOGIN_COOKIE}`),
),
).toBe(true);
});
it('uses session cookie as access credential when login success has no token payload', async () => {
const adapter = new NewApiAdapter();
const result = await adapter.login(baseUrl, COOKIE_ONLY_LOGIN_USERNAME, COOKIE_ONLY_LOGIN_PASSWORD);
expect(result.success).toBe(true);
expect(result.accessToken || '').toContain(`session=${COOKIE_ONLY_LOGIN_SESSION}`);
expect(result.accessToken || '').toContain(`acw_sc__v2=${ANYROUTER_CHALLENGE_ACW}`);
expect(result.accessToken || '').toContain(`cdn_sec_tc=${SHIELD_LOGIN_COOKIE}`);
});
it('detects cookie session values as session cookies for anyrouter-like deployments', async () => { it('detects cookie session values as session cookies for anyrouter-like deployments', async () => {
const adapter = new NewApiAdapter(); const adapter = new NewApiAdapter();
const result = await adapter.verifyToken(baseUrl, COOKIE_SESSION_TOKEN); const result = await adapter.verifyToken(baseUrl, COOKIE_SESSION_TOKEN);
+109 -13
View File
@@ -211,6 +211,24 @@ export class NewApiAdapter extends BasePlatformAdapter {
return { balance: quota, used, quota: total, todayIncome, todayQuotaConsumption }; return { balance: quota, used, quota: total, todayIncome, todayQuotaConsumption };
} }
private extractLoginAccessToken(payload: any): string | null {
const candidates: unknown[] = [
payload?.data,
payload?.token,
payload?.accessToken,
payload?.access_token,
payload?.data?.token,
payload?.data?.accessToken,
payload?.data?.access_token,
];
for (const candidate of candidates) {
if (typeof candidate !== 'string') continue;
const token = candidate.trim();
if (token) return token;
}
return null;
}
private buildDefaultTokenPayload(options?: CreateApiTokenOptions): Record<string, unknown> { private buildDefaultTokenPayload(options?: CreateApiTokenOptions): Record<string, unknown> {
const normalizedName = (options?.name || '').trim() || 'metapi'; const normalizedName = (options?.name || '').trim() || 'metapi';
const unlimitedQuota = options?.unlimitedQuota ?? true; const unlimitedQuota = options?.unlimitedQuota ?? true;
@@ -429,6 +447,32 @@ export class NewApiAdapter extends BasePlatformAdapter {
return output; return output;
} }
private hasUsableSessionCookie(cookieHeader: string): boolean {
if (!cookieHeader) return false;
const ignored = new Set(['acw_tc', 'acw_sc__v2', 'cdn_sec_tc']);
const pairs = cookieHeader.split(';').map((part) => part.trim()).filter(Boolean);
for (const pair of pairs) {
const eq = pair.indexOf('=');
if (eq <= 0) continue;
const name = pair.slice(0, eq).trim().toLowerCase();
if (!name || ignored.has(name)) continue;
if (
name === 'session'
|| name === 'token'
|| name === 'auth_token'
|| name === 'access_token'
|| name === 'jwt'
|| name === 'jwt_token'
|| name.includes('session')
|| name.includes('token')
|| name.includes('auth')
) {
return true;
}
}
return false;
}
private shouldFallbackToCookieCheckin(message?: string | null): boolean { private shouldFallbackToCookieCheckin(message?: string | null): boolean {
if (!message) return true; if (!message) return true;
const text = message.toLowerCase(); const text = message.toLowerCase();
@@ -449,7 +493,10 @@ export class NewApiAdapter extends BasePlatformAdapter {
); );
} }
private async fetchJsonRaw<T>(url: string, options?: UndiciRequestInit): Promise<T | null> { private async fetchJsonRawWithCookie<T>(
url: string,
options?: UndiciRequestInit,
): Promise<{ data: T | null; cookieHeader: string }> {
const { fetch } = await import('undici'); const { fetch } = await import('undici');
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -472,30 +519,34 @@ export class NewApiAdapter extends BasePlatformAdapter {
const proxiedRequestOptions = withSiteProxyRequestInit(url, requestOptions); const proxiedRequestOptions = withSiteProxyRequestInit(url, requestOptions);
const res = await fetch(url, proxiedRequestOptions); const res = await fetch(url, proxiedRequestOptions);
const text = await res.text(); const text = await res.text();
const parsed = this.parseJsonSafe<T>(text);
if (parsed) return parsed;
if (!this.isShieldChallenge(res.headers.get('content-type') || '', text)) {
return null;
}
if (!cookieHeader) {
return null;
}
const getSetCookie = (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie; const getSetCookie = (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie;
if (typeof getSetCookie === 'function') { if (typeof getSetCookie === 'function') {
cookieHeader = this.mergeSetCookiePairs(cookieHeader, getSetCookie.call(res.headers) || []); cookieHeader = this.mergeSetCookiePairs(cookieHeader, getSetCookie.call(res.headers) || []);
} }
const parsed = this.parseJsonSafe<T>(text);
if (parsed) return { data: parsed, cookieHeader };
if (!this.isShieldChallenge(res.headers.get('content-type') || '', text)) {
return { data: null, cookieHeader };
}
if (!cookieHeader) {
return { data: null, cookieHeader };
}
const acwScV2 = this.solveAcwScV2(text); const acwScV2 = this.solveAcwScV2(text);
if (!acwScV2) { if (!acwScV2) {
return null; return { data: null, cookieHeader };
} }
cookieHeader = this.upsertCookie(cookieHeader, 'acw_sc__v2', acwScV2); cookieHeader = this.upsertCookie(cookieHeader, 'acw_sc__v2', acwScV2);
headers['Cookie'] = cookieHeader; headers['Cookie'] = cookieHeader;
} }
return null; return { data: null, cookieHeader };
}
private async fetchJsonRaw<T>(url: string, options?: UndiciRequestInit): Promise<T | null> {
const result = await this.fetchJsonRawWithCookie<T>(url, options);
return result.data;
} }
private async fetchUserSelfByCookie( private async fetchUserSelfByCookie(
@@ -622,6 +673,51 @@ export class NewApiAdapter extends BasePlatformAdapter {
return null; return null;
} }
override async login(
baseUrl: string,
username: string,
password: string,
): Promise<{ success: boolean; accessToken?: string; username?: string; message?: string }> {
try {
const { data: res, cookieHeader } = await this.fetchJsonRawWithCookie<any>(`${baseUrl}/api/user/login`, {
method: 'POST',
body: JSON.stringify({ username, password }),
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
if (!res) {
return { success: false, message: 'shield challenge blocked login' };
}
const accessToken = this.extractLoginAccessToken(res);
if (res?.success && accessToken) {
return {
success: true,
accessToken,
username,
};
}
if (res?.success && this.hasUsableSessionCookie(cookieHeader)) {
return {
success: true,
accessToken: cookieHeader,
username,
};
}
return {
success: false,
message: this.extractResponseMessage(res) || '登录失败:未获取到可用会话凭据,请改用 Cookie/Token 导入',
};
} catch (err: any) {
return {
success: false,
message: this.formatRequestErrorMessage(err) || err?.message || '登录请求失败',
};
}
}
override async verifyToken(baseUrl: string, token: string, platformUserId?: number): Promise<TokenVerifyResult> { override async verifyToken(baseUrl: string, token: string, platformUserId?: number): Promise<TokenVerifyResult> {
const openAiModels = await this.getOpenAiModels(baseUrl, token); const openAiModels = await this.getOpenAiModels(baseUrl, token);
if (openAiModels.length > 0) { if (openAiModels.length > 0) {
@@ -0,0 +1,110 @@
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 ConfigModule = typeof import('../config.js');
type RetentionModule = typeof import('./proxyLogRetentionService.js');
describe('proxyLogRetentionService', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let config: ConfigModule['config'];
let cleanupExpiredProxyLogs: RetentionModule['cleanupExpiredProxyLogs'];
let getProxyLogRetentionCutoffUtc: RetentionModule['getProxyLogRetentionCutoffUtc'];
let dataDir = '';
let originalRetentionDays = 0;
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-proxy-log-retention-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const configModule = await import('../config.js');
const retentionModule = await import('./proxyLogRetentionService.js');
db = dbModule.db;
schema = dbModule.schema;
config = configModule.config;
cleanupExpiredProxyLogs = retentionModule.cleanupExpiredProxyLogs;
getProxyLogRetentionCutoffUtc = retentionModule.getProxyLogRetentionCutoffUtc;
originalRetentionDays = config.proxyLogRetentionDays;
});
beforeEach(() => {
db.delete(schema.proxyLogs).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
});
afterAll(() => {
config.proxyLogRetentionDays = originalRetentionDays;
delete process.env.DATA_DIR;
});
function seedAccount(platform = 'new-api') {
const site = db.insert(schema.sites).values({
name: `retention-site-${platform}`,
url: `https://retention-${platform}.example.com`,
platform,
status: 'active',
}).returning().get();
return db.insert(schema.accounts).values({
siteId: site.id,
username: `retention-${platform}`,
accessToken: `access-${platform}`,
apiToken: `api-${platform}`,
status: 'active',
}).returning().get();
}
it('deletes proxy logs older than retention days', () => {
config.proxyLogRetentionDays = 7;
const account = seedAccount('new-api');
db.insert(schema.proxyLogs).values([
{
accountId: account.id,
modelRequested: 'gpt-4o-mini',
status: 'success',
createdAt: '2026-02-20 12:00:00',
},
{
accountId: account.id,
modelRequested: 'gpt-4o-mini',
status: 'success',
createdAt: '2026-03-01 12:00:00',
},
]).run();
const result = cleanupExpiredProxyLogs(Date.parse('2026-03-04T00:00:00Z'));
expect(result.deleted).toBe(1);
const rows = db.select().from(schema.proxyLogs).all();
expect(rows).toHaveLength(1);
expect(rows[0]?.createdAt).toBe('2026-03-01 12:00:00');
});
it('skips cleanup when retention is disabled', () => {
config.proxyLogRetentionDays = 0;
const account = seedAccount('new-api');
db.insert(schema.proxyLogs).values({
accountId: account.id,
modelRequested: 'gpt-4o-mini',
status: 'success',
createdAt: '2026-01-01 00:00:00',
}).run();
const cutoff = getProxyLogRetentionCutoffUtc(Date.parse('2026-03-04T00:00:00Z'));
expect(cutoff).toBeNull();
const result = cleanupExpiredProxyLogs(Date.parse('2026-03-04T00:00:00Z'));
expect(result.deleted).toBe(0);
const rows = db.select().from(schema.proxyLogs).all();
expect(rows).toHaveLength(1);
});
});
@@ -0,0 +1,70 @@
import { lt } from 'drizzle-orm';
import { config } from '../config.js';
import { db, schema } from '../db/index.js';
import { formatUtcSqlDateTime } from './localTimeService.js';
const DAY_MS = 24 * 60 * 60 * 1000;
let retentionTimer: ReturnType<typeof setInterval> | null = null;
export function getProxyLogRetentionCutoffUtc(nowMs = Date.now()): string | null {
const days = Math.max(0, Math.trunc(config.proxyLogRetentionDays));
if (days <= 0) return null;
return formatUtcSqlDateTime(new Date(nowMs - days * DAY_MS));
}
export function cleanupExpiredProxyLogs(nowMs = Date.now()): {
enabled: boolean;
retentionDays: number;
cutoffUtc: string | null;
deleted: number;
} {
const retentionDays = Math.max(0, Math.trunc(config.proxyLogRetentionDays));
const cutoffUtc = getProxyLogRetentionCutoffUtc(nowMs);
if (!cutoffUtc) {
return {
enabled: false,
retentionDays,
cutoffUtc: null,
deleted: 0,
};
}
const deleted = db.delete(schema.proxyLogs)
.where(lt(schema.proxyLogs.createdAt, cutoffUtc))
.run()
.changes;
return {
enabled: true,
retentionDays,
cutoffUtc,
deleted,
};
}
export function startProxyLogRetentionService(): void {
if (retentionTimer) return;
const intervalMinutes = Math.max(1, Math.trunc(config.proxyLogRetentionPruneIntervalMinutes));
const intervalMs = intervalMinutes * 60 * 1000;
const runCleanup = () => {
try {
const result = cleanupExpiredProxyLogs();
if (!result.enabled || result.deleted <= 0) return;
console.info(`[proxy-log-retention] deleted ${result.deleted} logs before ${result.cutoffUtc}`);
} catch (error) {
console.warn('[proxy-log-retention] cleanup failed', error);
}
};
runCleanup();
retentionTimer = setInterval(runCleanup, intervalMs);
retentionTimer.unref?.();
}
export function stopProxyLogRetentionService(): void {
if (!retentionTimer) return;
clearInterval(retentionTimer);
retentionTimer = null;
}
@@ -0,0 +1,103 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
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');
type ConfigModule = typeof import('../config.js');
describe('TokenRouter runtime cache', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let TokenRouter: TokenRouterModule['TokenRouter'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let config: ConfigModule['config'];
let dataDir = '';
let originalCacheTtlMs = 0;
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-token-router-cache-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const tokenRouterModule = await import('./tokenRouter.js');
const configModule = await import('../config.js');
db = dbModule.db;
schema = dbModule.schema;
TokenRouter = tokenRouterModule.TokenRouter;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
config = configModule.config;
originalCacheTtlMs = config.tokenRouterCacheTtlMs;
});
beforeEach(() => {
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
config.tokenRouterCacheTtlMs = 60_000;
invalidateTokenRouterCache();
});
afterAll(() => {
config.tokenRouterCacheTtlMs = originalCacheTtlMs;
invalidateTokenRouterCache();
delete process.env.DATA_DIR;
});
it('keeps route snapshot inside TTL until explicit invalidation', () => {
const site = db.insert(schema.sites).values({
name: 'cache-site',
url: 'https://cache-site.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'cache-user',
accessToken: 'cache-access-token',
apiToken: 'cache-api-token',
status: 'active',
}).returning().get();
const token = db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'cache-token',
token: 'sk-cache-token',
enabled: true,
isDefault: true,
}).returning().get();
const route = db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
enabled: true,
}).returning().get();
db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: token.id,
priority: 0,
weight: 10,
enabled: true,
}).run();
const router = new TokenRouter();
expect(router.selectChannel('gpt-4o-mini')).toBeTruthy();
db.delete(schema.routeChannels).where(eq(schema.routeChannels.routeId, route.id)).run();
db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run();
const cachedSelection = router.selectChannel('gpt-4o-mini');
expect(cachedSelection).toBeTruthy();
invalidateTokenRouterCache();
const refreshedSelection = router.selectChannel('gpt-4o-mini');
expect(refreshedSelection).toBeNull();
});
});
@@ -10,6 +10,7 @@ describe('TokenRouter downstream policy', () => {
let db: DbModule['db']; let db: DbModule['db'];
let schema: DbModule['schema']; let schema: DbModule['schema'];
let TokenRouter: TokenRouterModule['TokenRouter']; let TokenRouter: TokenRouterModule['TokenRouter'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let dataDir = ''; let dataDir = '';
beforeAll(async () => { beforeAll(async () => {
@@ -22,6 +23,7 @@ describe('TokenRouter downstream policy', () => {
db = dbModule.db; db = dbModule.db;
schema = dbModule.schema; schema = dbModule.schema;
TokenRouter = tokenRouterModule.TokenRouter; TokenRouter = tokenRouterModule.TokenRouter;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
}); });
beforeEach(() => { beforeEach(() => {
@@ -30,9 +32,11 @@ describe('TokenRouter downstream policy', () => {
db.delete(schema.accountTokens).run(); db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run(); db.delete(schema.accounts).run();
db.delete(schema.sites).run(); db.delete(schema.sites).run();
invalidateTokenRouterCache();
}); });
afterAll(() => { afterAll(() => {
invalidateTokenRouterCache();
delete process.env.DATA_DIR; delete process.env.DATA_DIR;
}); });
@@ -10,6 +10,7 @@ describe('TokenRouter patterns and model mapping', () => {
let db: DbModule['db']; let db: DbModule['db'];
let schema: DbModule['schema']; let schema: DbModule['schema'];
let TokenRouter: TokenRouterModule['TokenRouter']; let TokenRouter: TokenRouterModule['TokenRouter'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let dataDir = ''; let dataDir = '';
let idSeed = 0; let idSeed = 0;
@@ -28,6 +29,7 @@ describe('TokenRouter patterns and model mapping', () => {
db = dbModule.db; db = dbModule.db;
schema = dbModule.schema; schema = dbModule.schema;
TokenRouter = tokenRouterModule.TokenRouter; TokenRouter = tokenRouterModule.TokenRouter;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
}); });
beforeEach(() => { beforeEach(() => {
@@ -37,9 +39,11 @@ describe('TokenRouter patterns and model mapping', () => {
db.delete(schema.accountTokens).run(); db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run(); db.delete(schema.accounts).run();
db.delete(schema.sites).run(); db.delete(schema.sites).run();
invalidateTokenRouterCache();
}); });
afterAll(() => { afterAll(() => {
invalidateTokenRouterCache();
delete process.env.DATA_DIR; delete process.env.DATA_DIR;
}); });
@@ -23,6 +23,7 @@ describe('TokenRouter selection scoring', () => {
let db: DbModule['db']; let db: DbModule['db'];
let schema: DbModule['schema']; let schema: DbModule['schema'];
let TokenRouter: TokenRouterModule['TokenRouter']; let TokenRouter: TokenRouterModule['TokenRouter'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let config: ConfigModule['config']; let config: ConfigModule['config'];
let dataDir = ''; let dataDir = '';
let idSeed = 0; let idSeed = 0;
@@ -45,6 +46,7 @@ describe('TokenRouter selection scoring', () => {
db = dbModule.db; db = dbModule.db;
schema = dbModule.schema; schema = dbModule.schema;
TokenRouter = tokenRouterModule.TokenRouter; TokenRouter = tokenRouterModule.TokenRouter;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
config = configModule.config; config = configModule.config;
originalRoutingWeights = { ...config.routingWeights }; originalRoutingWeights = { ...config.routingWeights };
originalRoutingFallbackUnitCost = config.routingFallbackUnitCost; originalRoutingFallbackUnitCost = config.routingFallbackUnitCost;
@@ -59,11 +61,13 @@ describe('TokenRouter selection scoring', () => {
db.delete(schema.accountTokens).run(); db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run(); db.delete(schema.accounts).run();
db.delete(schema.sites).run(); db.delete(schema.sites).run();
invalidateTokenRouterCache();
}); });
afterAll(() => { afterAll(() => {
config.routingWeights = { ...originalRoutingWeights }; config.routingWeights = { ...originalRoutingWeights };
config.routingFallbackUnitCost = originalRoutingFallbackUnitCost; config.routingFallbackUnitCost = originalRoutingFallbackUnitCost;
invalidateTokenRouterCache();
delete process.env.DATA_DIR; delete process.env.DATA_DIR;
}); });
@@ -11,6 +11,7 @@ describe('TokenRouter site status guard', () => {
let db: DbModule['db']; let db: DbModule['db'];
let schema: DbModule['schema']; let schema: DbModule['schema'];
let TokenRouter: TokenRouterModule['TokenRouter']; let TokenRouter: TokenRouterModule['TokenRouter'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let dataDir = ''; let dataDir = '';
beforeAll(async () => { beforeAll(async () => {
@@ -23,6 +24,7 @@ describe('TokenRouter site status guard', () => {
db = dbModule.db; db = dbModule.db;
schema = dbModule.schema; schema = dbModule.schema;
TokenRouter = tokenRouterModule.TokenRouter; TokenRouter = tokenRouterModule.TokenRouter;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
}); });
beforeEach(() => { beforeEach(() => {
@@ -33,9 +35,11 @@ describe('TokenRouter site status guard', () => {
db.delete(schema.accountTokens).run(); db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run(); db.delete(schema.accounts).run();
db.delete(schema.sites).run(); db.delete(schema.sites).run();
invalidateTokenRouterCache();
}); });
afterAll(() => { afterAll(() => {
invalidateTokenRouterCache();
delete process.env.DATA_DIR; delete process.env.DATA_DIR;
}); });
File diff suppressed because it is too large Load Diff