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:
@@ -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),
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -120,6 +120,40 @@ function getNextAccountSortOrder(): number {
|
|||||||
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 {
|
||||||
total: results.length,
|
total: results.length,
|
||||||
@@ -299,7 +333,12 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
// 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);
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js';
|
import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||||
|
|
||||||
function isExactModelPattern(modelPattern: string): boolean {
|
function isExactModelPattern(modelPattern: string): boolean {
|
||||||
@@ -432,6 +432,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
}).returning().get();
|
}).returning().get();
|
||||||
|
|
||||||
populateRouteChannelsByModelPattern(route.id, body.modelPattern);
|
populateRouteChannelsByModelPattern(route.id, body.modelPattern);
|
||||||
|
invalidateTokenRouterCache();
|
||||||
return route;
|
return route;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -449,6 +450,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
updates.updatedAt = new Date().toISOString();
|
updates.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run();
|
db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run();
|
||||||
|
invalidateTokenRouterCache();
|
||||||
return db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
|
return db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -456,6 +458,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => {
|
app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseInt(request.params.id, 10);
|
||||||
db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run();
|
db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run();
|
||||||
|
invalidateTokenRouterCache();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -494,7 +497,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
return reply.code(400).send({ success: false, message: '该来源模型的通道已存在' });
|
return reply.code(400).send({ success: false, message: '该来源模型的通道已存在' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.insert(schema.routeChannels).values({
|
const created = db.insert(schema.routeChannels).values({
|
||||||
routeId,
|
routeId,
|
||||||
accountId: body.accountId,
|
accountId: body.accountId,
|
||||||
tokenId: body.tokenId,
|
tokenId: body.tokenId,
|
||||||
@@ -502,6 +505,8 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
priority: body.priority ?? 0,
|
priority: body.priority ?? 0,
|
||||||
weight: body.weight ?? 10,
|
weight: body.weight ?? 10,
|
||||||
}).returning().get();
|
}).returning().get();
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
return created;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Batch update channel priorities
|
// Batch update channel priorities
|
||||||
@@ -531,6 +536,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
const updatedChannels = db.select().from(schema.routeChannels)
|
const updatedChannels = db.select().from(schema.routeChannels)
|
||||||
.where(inArray(schema.routeChannels.id, channelIds))
|
.where(inArray(schema.routeChannels.id, channelIds))
|
||||||
.all();
|
.all();
|
||||||
|
invalidateTokenRouterCache();
|
||||||
return { success: true, channels: updatedChannels };
|
return { success: true, channels: updatedChannels };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -575,6 +581,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
|
db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
|
||||||
|
invalidateTokenRouterCache();
|
||||||
return db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
|
return db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -582,6 +589,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => {
|
app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => {
|
||||||
const channelId = parseInt(request.params.channelId, 10);
|
const channelId = parseInt(request.params.channelId, 10);
|
||||||
db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
|
db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
|
||||||
|
invalidateTokenRouterCache();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
|
||||||
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
||||||
@@ -334,6 +335,8 @@ export function rebuildTokenRoutesFromAvailability() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
models: modelTokens.size,
|
models: modelTokens.size,
|
||||||
createdRoutes,
|
createdRoutes,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,97 @@ type FailureAwareChannel = {
|
|||||||
const RECENT_FAILURE_AVOID_SEC = 10 * 60;
|
const RECENT_FAILURE_AVOID_SEC = 10 * 60;
|
||||||
const MIN_EFFECTIVE_UNIT_COST = 1e-6;
|
const MIN_EFFECTIVE_UNIT_COST = 1e-6;
|
||||||
|
|
||||||
|
type RouteRow = typeof schema.tokenRoutes.$inferSelect;
|
||||||
|
type ChannelRow = typeof schema.routeChannels.$inferSelect;
|
||||||
|
|
||||||
|
type RouteCacheSnapshot = {
|
||||||
|
loadedAt: number;
|
||||||
|
routes: RouteRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RouteMatchCacheSnapshot = {
|
||||||
|
loadedAt: number;
|
||||||
|
match: RouteMatch;
|
||||||
|
};
|
||||||
|
|
||||||
|
let routeCacheSnapshot: RouteCacheSnapshot = {
|
||||||
|
loadedAt: 0,
|
||||||
|
routes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeMatchCache = new Map<number, RouteMatchCacheSnapshot>();
|
||||||
|
|
||||||
|
function resolveTokenRouterCacheTtlMs(): number {
|
||||||
|
const raw = Math.trunc(config.tokenRouterCacheTtlMs || 0);
|
||||||
|
return Math.max(100, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCacheFresh(loadedAt: number, nowMs: number): boolean {
|
||||||
|
return nowMs - loadedAt < resolveTokenRouterCacheTtlMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEnabledRoutes(nowMs = Date.now()): RouteRow[] {
|
||||||
|
if (isCacheFresh(routeCacheSnapshot.loadedAt, nowMs)) {
|
||||||
|
return routeCacheSnapshot.routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes = db.select().from(schema.tokenRoutes)
|
||||||
|
.where(eq(schema.tokenRoutes.enabled, true))
|
||||||
|
.all();
|
||||||
|
routeCacheSnapshot = {
|
||||||
|
loadedAt: nowMs,
|
||||||
|
routes,
|
||||||
|
};
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRouteMatch(route: RouteRow, nowMs = Date.now()): RouteMatch {
|
||||||
|
const cached = routeMatchCache.get(route.id);
|
||||||
|
if (cached && isCacheFresh(cached.loadedAt, nowMs)) {
|
||||||
|
return cached.match;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channels = db
|
||||||
|
.select()
|
||||||
|
.from(schema.routeChannels)
|
||||||
|
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
|
||||||
|
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||||
|
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
|
||||||
|
.where(eq(schema.routeChannels.routeId, route.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const mapped = channels.map((row) => ({
|
||||||
|
channel: row.route_channels,
|
||||||
|
account: row.accounts,
|
||||||
|
site: row.sites,
|
||||||
|
token: row.account_tokens,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const match = { route, channels: mapped };
|
||||||
|
routeMatchCache.set(route.id, {
|
||||||
|
loadedAt: nowMs,
|
||||||
|
match,
|
||||||
|
});
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchCachedChannel(channelId: number, apply: (channel: ChannelRow) => void): void {
|
||||||
|
for (const entry of routeMatchCache.values()) {
|
||||||
|
const target = entry.match.channels.find((item) => item.channel.id === channelId);
|
||||||
|
if (!target) continue;
|
||||||
|
apply(target.channel);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateTokenRouterCache(): void {
|
||||||
|
routeCacheSnapshot = {
|
||||||
|
loadedAt: 0,
|
||||||
|
routes: [],
|
||||||
|
};
|
||||||
|
routeMatchCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
function isSiteDisabled(status?: string | null): boolean {
|
function isSiteDisabled(status?: string | null): boolean {
|
||||||
return (status || 'active') === 'disabled';
|
return (status || 'active') === 'disabled';
|
||||||
}
|
}
|
||||||
@@ -632,14 +723,27 @@ export class TokenRouter {
|
|||||||
recordSuccess(channelId: number, latencyMs: number, cost: number) {
|
recordSuccess(channelId: number, latencyMs: number, cost: number) {
|
||||||
const ch = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
|
const ch = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
|
||||||
if (!ch) return;
|
if (!ch) return;
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const nextSuccessCount = (ch.successCount ?? 0) + 1;
|
||||||
|
const nextTotalLatencyMs = (ch.totalLatencyMs ?? 0) + latencyMs;
|
||||||
|
const nextTotalCost = (ch.totalCost ?? 0) + cost;
|
||||||
db.update(schema.routeChannels).set({
|
db.update(schema.routeChannels).set({
|
||||||
successCount: (ch.successCount ?? 0) + 1,
|
successCount: nextSuccessCount,
|
||||||
totalLatencyMs: (ch.totalLatencyMs ?? 0) + latencyMs,
|
totalLatencyMs: nextTotalLatencyMs,
|
||||||
totalCost: (ch.totalCost ?? 0) + cost,
|
totalCost: nextTotalCost,
|
||||||
lastUsedAt: new Date().toISOString(),
|
lastUsedAt: nowIso,
|
||||||
cooldownUntil: null,
|
cooldownUntil: null,
|
||||||
lastFailAt: null,
|
lastFailAt: null,
|
||||||
}).where(eq(schema.routeChannels.id, channelId)).run();
|
}).where(eq(schema.routeChannels.id, channelId)).run();
|
||||||
|
|
||||||
|
patchCachedChannel(channelId, (channel) => {
|
||||||
|
channel.successCount = nextSuccessCount;
|
||||||
|
channel.totalLatencyMs = nextTotalLatencyMs;
|
||||||
|
channel.totalCost = nextTotalCost;
|
||||||
|
channel.lastUsedAt = nowIso;
|
||||||
|
channel.cooldownUntil = null;
|
||||||
|
channel.lastFailAt = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -652,19 +756,25 @@ export class TokenRouter {
|
|||||||
// Exponential backoff cooldown: 30s, 60s, 120s, 240s, max 5min
|
// Exponential backoff cooldown: 30s, 60s, 120s, 240s, max 5min
|
||||||
const cooldownSec = Math.min(30 * Math.pow(2, failCount - 1), 300);
|
const cooldownSec = Math.min(30 * Math.pow(2, failCount - 1), 300);
|
||||||
const cooldownUntil = new Date(Date.now() + cooldownSec * 1000).toISOString();
|
const cooldownUntil = new Date(Date.now() + cooldownSec * 1000).toISOString();
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
db.update(schema.routeChannels).set({
|
db.update(schema.routeChannels).set({
|
||||||
failCount,
|
failCount,
|
||||||
lastFailAt: new Date().toISOString(),
|
lastFailAt: nowIso,
|
||||||
cooldownUntil,
|
cooldownUntil,
|
||||||
}).where(eq(schema.routeChannels.id, channelId)).run();
|
}).where(eq(schema.routeChannels.id, channelId)).run();
|
||||||
|
|
||||||
|
patchCachedChannel(channelId, (channel) => {
|
||||||
|
channel.failCount = failCount;
|
||||||
|
channel.lastFailAt = nowIso;
|
||||||
|
channel.cooldownUntil = cooldownUntil;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all available models (aggregated from all routes).
|
* Get all available models (aggregated from all routes).
|
||||||
*/
|
*/
|
||||||
getAvailableModels(): string[] {
|
getAvailableModels(): string[] {
|
||||||
const routes = db.select().from(schema.tokenRoutes)
|
const routes = loadEnabledRoutes();
|
||||||
.where(eq(schema.tokenRoutes.enabled, true)).all();
|
|
||||||
const exposed = routes
|
const exposed = routes
|
||||||
.map((route) => getExposedModelNameForRoute(route).trim())
|
.map((route) => getExposedModelNameForRoute(route).trim())
|
||||||
.filter((name) => name.length > 0);
|
.filter((name) => name.length > 0);
|
||||||
@@ -674,8 +784,7 @@ export class TokenRouter {
|
|||||||
// --- Private methods ---
|
// --- Private methods ---
|
||||||
|
|
||||||
private findRoute(model: string, downstreamPolicy: DownstreamRoutingPolicy): RouteMatch | null {
|
private findRoute(model: string, downstreamPolicy: DownstreamRoutingPolicy): RouteMatch | null {
|
||||||
let routes = db.select().from(schema.tokenRoutes)
|
let routes = loadEnabledRoutes();
|
||||||
.where(eq(schema.tokenRoutes.enabled, true)).all();
|
|
||||||
|
|
||||||
const supportedPatterns = Array.isArray(downstreamPolicy.supportedModels)
|
const supportedPatterns = Array.isArray(downstreamPolicy.supportedModels)
|
||||||
? downstreamPolicy.supportedModels
|
? downstreamPolicy.supportedModels
|
||||||
@@ -702,33 +811,14 @@ export class TokenRouter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = db.select().from(schema.tokenRoutes)
|
const route = loadEnabledRoutes().find((item) => item.id === routeId);
|
||||||
.where(eq(schema.tokenRoutes.id, routeId))
|
if (!route) return null;
|
||||||
.get();
|
|
||||||
if (!route || !route.enabled) return null;
|
|
||||||
|
|
||||||
return this.loadRouteMatch(route);
|
return this.loadRouteMatch(route);
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadRouteMatch(route: typeof schema.tokenRoutes.$inferSelect): RouteMatch {
|
private loadRouteMatch(route: typeof schema.tokenRoutes.$inferSelect): RouteMatch {
|
||||||
// Get channels with account, site and token info
|
return loadRouteMatch(route);
|
||||||
const channels = db
|
|
||||||
.select()
|
|
||||||
.from(schema.routeChannels)
|
|
||||||
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
|
|
||||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
|
||||||
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
|
|
||||||
.where(eq(schema.routeChannels.routeId, route.id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
const mapped = channels.map((row) => ({
|
|
||||||
channel: row.route_channels,
|
|
||||||
account: row.accounts,
|
|
||||||
site: row.sites,
|
|
||||||
token: row.account_tokens,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { route, channels: mapped };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveChannelTokenValue(candidate: {
|
private resolveChannelTokenValue(candidate: {
|
||||||
|
|||||||
Reference in New Issue
Block a user