forked from boos/BoosAPI
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:
+352
-349
@@ -1,350 +1,353 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getAdapter } from './platforms/index.js';
|
||||
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js';
|
||||
import { resolvePlatformUserId } from './accountExtraConfig.js';
|
||||
|
||||
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
|
||||
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
||||
const MODEL_REFRESH_BATCH_SIZE = 3;
|
||||
|
||||
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 isExactModelPattern(modelPattern: string): boolean {
|
||||
const normalized = modelPattern.trim();
|
||||
if (!normalized) return false;
|
||||
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;
|
||||
try {
|
||||
return await Promise.race([
|
||||
fn(),
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshModelsForAccount(accountId: number) {
|
||||
const row = db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.get();
|
||||
|
||||
if (!row) {
|
||||
return { accountId, refreshed: false, modelCount: 0, reason: 'account_not_found' };
|
||||
}
|
||||
|
||||
const account = row.accounts;
|
||||
const site = row.sites;
|
||||
const adapter = getAdapter(site.platform);
|
||||
|
||||
const accountTokens = db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, accountId))
|
||||
.all();
|
||||
|
||||
db.delete(schema.modelAvailability)
|
||||
.where(eq(schema.modelAvailability.accountId, accountId))
|
||||
.run();
|
||||
|
||||
for (const token of accountTokens) {
|
||||
db.delete(schema.tokenModelAvailability)
|
||||
.where(eq(schema.tokenModelAvailability.tokenId, token.id))
|
||||
.run();
|
||||
}
|
||||
|
||||
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' };
|
||||
}
|
||||
|
||||
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
|
||||
let discoveredApiToken: string | null = null;
|
||||
|
||||
if (!account.apiToken && account.accessToken) {
|
||||
try {
|
||||
discoveredApiToken = await withTimeout(
|
||||
() => adapter.getApiToken(site.url, account.accessToken, platformUserId),
|
||||
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' });
|
||||
db.update(schema.accounts).set({
|
||||
apiToken: discoveredApiToken,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.accounts.id, account.id)).run();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let enabledTokens = db.select()
|
||||
.from(schema.accountTokens)
|
||||
.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) {
|
||||
const fallback = discoveredApiToken || account.apiToken || null;
|
||||
if (fallback) {
|
||||
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
|
||||
enabledTokens = db.select()
|
||||
.from(schema.accountTokens)
|
||||
.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>();
|
||||
let scannedTokenCount = 0;
|
||||
let discoveredByCredential = false;
|
||||
const attemptedCredentials = new Set<string>();
|
||||
|
||||
const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => {
|
||||
for (const modelName of models) {
|
||||
accountModels.add(modelName);
|
||||
const prev = modelLatency.get(modelName);
|
||||
if (prev === undefined || prev === null) {
|
||||
modelLatency.set(modelName, latencyMs);
|
||||
continue;
|
||||
}
|
||||
if (latencyMs === null) continue;
|
||||
if (latencyMs < prev) modelLatency.set(modelName, latencyMs);
|
||||
}
|
||||
};
|
||||
|
||||
const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => {
|
||||
const credential = (credentialRaw || '').trim();
|
||||
if (!credential) return;
|
||||
if (attemptedCredentials.has(credential)) return;
|
||||
attemptedCredentials.add(credential);
|
||||
|
||||
const startedAt = Date.now();
|
||||
let models: string[] = [];
|
||||
try {
|
||||
models = normalizeModels(
|
||||
await withTimeout(
|
||||
() => adapter.getModels(site.url, credential, platformUserId),
|
||||
MODEL_DISCOVERY_TIMEOUT_MS,
|
||||
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
models = [];
|
||||
}
|
||||
if (models.length === 0) return;
|
||||
discoveredByCredential = true;
|
||||
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);
|
||||
await discoverModelsWithCredential(discoveredApiToken);
|
||||
await discoverModelsWithCredential(account.accessToken);
|
||||
|
||||
for (const token of enabledTokens) {
|
||||
const startedAt = Date.now();
|
||||
let models: string[] = [];
|
||||
|
||||
try {
|
||||
models = normalizeModels(
|
||||
await withTimeout(
|
||||
() => adapter.getModels(site.url, token.token, platformUserId),
|
||||
MODEL_DISCOVERY_TIMEOUT_MS,
|
||||
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
models = [];
|
||||
}
|
||||
|
||||
if (models.length === 0) continue;
|
||||
|
||||
const latencyMs = Date.now() - startedAt;
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
db.insert(schema.tokenModelAvailability).values(
|
||||
models.map((modelName) => ({
|
||||
tokenId: token.id,
|
||||
modelName,
|
||||
available: true,
|
||||
latencyMs,
|
||||
checkedAt,
|
||||
})),
|
||||
).run();
|
||||
|
||||
scannedTokenCount++;
|
||||
mergeDiscoveredModels(models, latencyMs);
|
||||
}
|
||||
|
||||
if (accountModels.size > 0) {
|
||||
const checkedAt = new Date().toISOString();
|
||||
db.insert(schema.modelAvailability).values(
|
||||
Array.from(accountModels).map((modelName) => ({
|
||||
accountId: account.id,
|
||||
modelName,
|
||||
available: true,
|
||||
latencyMs: modelLatency.get(modelName) ?? null,
|
||||
checkedAt,
|
||||
})),
|
||||
).run();
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
refreshed: true,
|
||||
modelCount: accountModels.size,
|
||||
tokenScanned: scannedTokenCount,
|
||||
discoveredByCredential,
|
||||
discoveredApiToken: !!discoveredApiToken,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshModelsForAllActiveAccounts() {
|
||||
const accounts = db.select({ id: schema.accounts.id }).from(schema.accounts)
|
||||
.where(eq(schema.accounts.status, 'active'))
|
||||
.all();
|
||||
|
||||
const results = [];
|
||||
for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) {
|
||||
const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE);
|
||||
const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id)));
|
||||
results.push(...batchResults);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function rebuildTokenRoutesFromAvailability() {
|
||||
const tokenRows = db.select().from(schema.tokenModelAvailability)
|
||||
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
|
||||
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.tokenModelAvailability.available, true),
|
||||
eq(schema.accountTokens.enabled, true),
|
||||
eq(schema.accounts.status, 'active'),
|
||||
eq(schema.sites.status, 'active'),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
const modelTokens = new Map<string, Map<number, number>>();
|
||||
for (const row of tokenRows) {
|
||||
const modelName = row.token_model_availability.modelName;
|
||||
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();
|
||||
|
||||
let createdRoutes = 0;
|
||||
let createdChannels = 0;
|
||||
let removedChannels = 0;
|
||||
let removedRoutes = 0;
|
||||
|
||||
for (const [modelName, tokenAccountMap] of modelTokens.entries()) {
|
||||
let route = routes.find((r) => r.modelPattern === modelName);
|
||||
if (!route) {
|
||||
route = db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: modelName,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
routes.push(route);
|
||||
createdRoutes++;
|
||||
}
|
||||
|
||||
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);
|
||||
if (exists) continue;
|
||||
|
||||
const created = db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId,
|
||||
tokenId,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).returning().get();
|
||||
channels.push(created);
|
||||
createdChannels++;
|
||||
}
|
||||
|
||||
for (const channel of routeChannels) {
|
||||
if (channel.tokenId && desiredTokenIds.has(channel.tokenId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!channel.tokenId) {
|
||||
const preferred = getPreferredAccountToken(channel.accountId);
|
||||
if (preferred && desiredTokenIds.has(preferred.id)) {
|
||||
db.update(schema.routeChannels)
|
||||
.set({ tokenId: preferred.id })
|
||||
.where(eq(schema.routeChannels.id, channel.id))
|
||||
.run();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!channel.manualOverride) {
|
||||
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 modelPattern = (route.modelPattern || '').trim();
|
||||
if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const routeChannelCount = channels.filter((channel) => channel.routeId === route.id).length;
|
||||
if (routeChannelCount > 0) {
|
||||
removedChannels += routeChannelCount;
|
||||
}
|
||||
|
||||
const deleted = db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run().changes;
|
||||
if (deleted > 0) {
|
||||
removedRoutes += deleted;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
models: modelTokens.size,
|
||||
createdRoutes,
|
||||
createdChannels,
|
||||
removedChannels,
|
||||
removedRoutes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshModelsAndRebuildRoutes() {
|
||||
const refresh = await refreshModelsForAllActiveAccounts();
|
||||
const rebuild = rebuildTokenRoutesFromAvailability();
|
||||
return { refresh, rebuild };
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getAdapter } from './platforms/index.js';
|
||||
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.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 MODEL_REFRESH_BATCH_SIZE = 3;
|
||||
|
||||
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 isExactModelPattern(modelPattern: string): boolean {
|
||||
const normalized = modelPattern.trim();
|
||||
if (!normalized) return false;
|
||||
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;
|
||||
try {
|
||||
return await Promise.race([
|
||||
fn(),
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshModelsForAccount(accountId: number) {
|
||||
const row = db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.get();
|
||||
|
||||
if (!row) {
|
||||
return { accountId, refreshed: false, modelCount: 0, reason: 'account_not_found' };
|
||||
}
|
||||
|
||||
const account = row.accounts;
|
||||
const site = row.sites;
|
||||
const adapter = getAdapter(site.platform);
|
||||
|
||||
const accountTokens = db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, accountId))
|
||||
.all();
|
||||
|
||||
db.delete(schema.modelAvailability)
|
||||
.where(eq(schema.modelAvailability.accountId, accountId))
|
||||
.run();
|
||||
|
||||
for (const token of accountTokens) {
|
||||
db.delete(schema.tokenModelAvailability)
|
||||
.where(eq(schema.tokenModelAvailability.tokenId, token.id))
|
||||
.run();
|
||||
}
|
||||
|
||||
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' };
|
||||
}
|
||||
|
||||
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
|
||||
let discoveredApiToken: string | null = null;
|
||||
|
||||
if (!account.apiToken && account.accessToken) {
|
||||
try {
|
||||
discoveredApiToken = await withTimeout(
|
||||
() => adapter.getApiToken(site.url, account.accessToken, platformUserId),
|
||||
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' });
|
||||
db.update(schema.accounts).set({
|
||||
apiToken: discoveredApiToken,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.accounts.id, account.id)).run();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let enabledTokens = db.select()
|
||||
.from(schema.accountTokens)
|
||||
.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) {
|
||||
const fallback = discoveredApiToken || account.apiToken || null;
|
||||
if (fallback) {
|
||||
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
|
||||
enabledTokens = db.select()
|
||||
.from(schema.accountTokens)
|
||||
.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>();
|
||||
let scannedTokenCount = 0;
|
||||
let discoveredByCredential = false;
|
||||
const attemptedCredentials = new Set<string>();
|
||||
|
||||
const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => {
|
||||
for (const modelName of models) {
|
||||
accountModels.add(modelName);
|
||||
const prev = modelLatency.get(modelName);
|
||||
if (prev === undefined || prev === null) {
|
||||
modelLatency.set(modelName, latencyMs);
|
||||
continue;
|
||||
}
|
||||
if (latencyMs === null) continue;
|
||||
if (latencyMs < prev) modelLatency.set(modelName, latencyMs);
|
||||
}
|
||||
};
|
||||
|
||||
const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => {
|
||||
const credential = (credentialRaw || '').trim();
|
||||
if (!credential) return;
|
||||
if (attemptedCredentials.has(credential)) return;
|
||||
attemptedCredentials.add(credential);
|
||||
|
||||
const startedAt = Date.now();
|
||||
let models: string[] = [];
|
||||
try {
|
||||
models = normalizeModels(
|
||||
await withTimeout(
|
||||
() => adapter.getModels(site.url, credential, platformUserId),
|
||||
MODEL_DISCOVERY_TIMEOUT_MS,
|
||||
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
models = [];
|
||||
}
|
||||
if (models.length === 0) return;
|
||||
discoveredByCredential = true;
|
||||
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);
|
||||
await discoverModelsWithCredential(discoveredApiToken);
|
||||
await discoverModelsWithCredential(account.accessToken);
|
||||
|
||||
for (const token of enabledTokens) {
|
||||
const startedAt = Date.now();
|
||||
let models: string[] = [];
|
||||
|
||||
try {
|
||||
models = normalizeModels(
|
||||
await withTimeout(
|
||||
() => adapter.getModels(site.url, token.token, platformUserId),
|
||||
MODEL_DISCOVERY_TIMEOUT_MS,
|
||||
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
models = [];
|
||||
}
|
||||
|
||||
if (models.length === 0) continue;
|
||||
|
||||
const latencyMs = Date.now() - startedAt;
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
db.insert(schema.tokenModelAvailability).values(
|
||||
models.map((modelName) => ({
|
||||
tokenId: token.id,
|
||||
modelName,
|
||||
available: true,
|
||||
latencyMs,
|
||||
checkedAt,
|
||||
})),
|
||||
).run();
|
||||
|
||||
scannedTokenCount++;
|
||||
mergeDiscoveredModels(models, latencyMs);
|
||||
}
|
||||
|
||||
if (accountModels.size > 0) {
|
||||
const checkedAt = new Date().toISOString();
|
||||
db.insert(schema.modelAvailability).values(
|
||||
Array.from(accountModels).map((modelName) => ({
|
||||
accountId: account.id,
|
||||
modelName,
|
||||
available: true,
|
||||
latencyMs: modelLatency.get(modelName) ?? null,
|
||||
checkedAt,
|
||||
})),
|
||||
).run();
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
refreshed: true,
|
||||
modelCount: accountModels.size,
|
||||
tokenScanned: scannedTokenCount,
|
||||
discoveredByCredential,
|
||||
discoveredApiToken: !!discoveredApiToken,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshModelsForAllActiveAccounts() {
|
||||
const accounts = db.select({ id: schema.accounts.id }).from(schema.accounts)
|
||||
.where(eq(schema.accounts.status, 'active'))
|
||||
.all();
|
||||
|
||||
const results = [];
|
||||
for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) {
|
||||
const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE);
|
||||
const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id)));
|
||||
results.push(...batchResults);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function rebuildTokenRoutesFromAvailability() {
|
||||
const tokenRows = db.select().from(schema.tokenModelAvailability)
|
||||
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
|
||||
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.tokenModelAvailability.available, true),
|
||||
eq(schema.accountTokens.enabled, true),
|
||||
eq(schema.accounts.status, 'active'),
|
||||
eq(schema.sites.status, 'active'),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
const modelTokens = new Map<string, Map<number, number>>();
|
||||
for (const row of tokenRows) {
|
||||
const modelName = row.token_model_availability.modelName;
|
||||
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();
|
||||
|
||||
let createdRoutes = 0;
|
||||
let createdChannels = 0;
|
||||
let removedChannels = 0;
|
||||
let removedRoutes = 0;
|
||||
|
||||
for (const [modelName, tokenAccountMap] of modelTokens.entries()) {
|
||||
let route = routes.find((r) => r.modelPattern === modelName);
|
||||
if (!route) {
|
||||
route = db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: modelName,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
routes.push(route);
|
||||
createdRoutes++;
|
||||
}
|
||||
|
||||
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);
|
||||
if (exists) continue;
|
||||
|
||||
const created = db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId,
|
||||
tokenId,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).returning().get();
|
||||
channels.push(created);
|
||||
createdChannels++;
|
||||
}
|
||||
|
||||
for (const channel of routeChannels) {
|
||||
if (channel.tokenId && desiredTokenIds.has(channel.tokenId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!channel.tokenId) {
|
||||
const preferred = getPreferredAccountToken(channel.accountId);
|
||||
if (preferred && desiredTokenIds.has(preferred.id)) {
|
||||
db.update(schema.routeChannels)
|
||||
.set({ tokenId: preferred.id })
|
||||
.where(eq(schema.routeChannels.id, channel.id))
|
||||
.run();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!channel.manualOverride) {
|
||||
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 modelPattern = (route.modelPattern || '').trim();
|
||||
if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const routeChannelCount = channels.filter((channel) => channel.routeId === route.id).length;
|
||||
if (routeChannelCount > 0) {
|
||||
removedChannels += routeChannelCount;
|
||||
}
|
||||
|
||||
const deleted = db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run().changes;
|
||||
if (deleted > 0) {
|
||||
removedRoutes += deleted;
|
||||
}
|
||||
}
|
||||
|
||||
invalidateTokenRouterCache();
|
||||
|
||||
return {
|
||||
models: modelTokens.size,
|
||||
createdRoutes,
|
||||
createdChannels,
|
||||
removedChannels,
|
||||
removedRoutes,
|
||||
};
|
||||
}
|
||||
|
||||
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_CLOUDFLARE_530_TOKEN = 'checkin-cloudflare-530-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(
|
||||
`1771864970|${Buffer.from('username=linuxdo_131936').toString('base64')}|sig`,
|
||||
).toString('base64');
|
||||
@@ -57,6 +64,66 @@ describe('NewApiAdapter', () => {
|
||||
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.headers['new-api-user'] !== '11494') {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
@@ -322,6 +389,40 @@ describe('NewApiAdapter', () => {
|
||||
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 () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
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 };
|
||||
}
|
||||
|
||||
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> {
|
||||
const normalizedName = (options?.name || '').trim() || 'metapi';
|
||||
const unlimitedQuota = options?.unlimitedQuota ?? true;
|
||||
@@ -429,6 +447,32 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
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 {
|
||||
if (!message) return true;
|
||||
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 headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -472,30 +519,34 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
const proxiedRequestOptions = withSiteProxyRequestInit(url, requestOptions);
|
||||
const res = await fetch(url, proxiedRequestOptions);
|
||||
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;
|
||||
if (typeof getSetCookie === 'function') {
|
||||
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);
|
||||
if (!acwScV2) {
|
||||
return null;
|
||||
return { data: null, cookieHeader };
|
||||
}
|
||||
cookieHeader = this.upsertCookie(cookieHeader, 'acw_sc__v2', acwScV2);
|
||||
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(
|
||||
@@ -622,6 +673,51 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
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> {
|
||||
const openAiModels = await this.getOpenAiModels(baseUrl, token);
|
||||
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 schema: DbModule['schema'];
|
||||
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -22,6 +23,7 @@ describe('TokenRouter downstream policy', () => {
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
TokenRouter = tokenRouterModule.TokenRouter;
|
||||
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -30,9 +32,11 @@ describe('TokenRouter downstream policy', () => {
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
invalidateTokenRouterCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
invalidateTokenRouterCache();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ describe('TokenRouter patterns and model mapping', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||
let dataDir = '';
|
||||
let idSeed = 0;
|
||||
|
||||
@@ -28,6 +29,7 @@ describe('TokenRouter patterns and model mapping', () => {
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
TokenRouter = tokenRouterModule.TokenRouter;
|
||||
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -37,9 +39,11 @@ describe('TokenRouter patterns and model mapping', () => {
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
invalidateTokenRouterCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
invalidateTokenRouterCache();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ describe('TokenRouter selection scoring', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||
let config: ConfigModule['config'];
|
||||
let dataDir = '';
|
||||
let idSeed = 0;
|
||||
@@ -45,6 +46,7 @@ describe('TokenRouter selection scoring', () => {
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
TokenRouter = tokenRouterModule.TokenRouter;
|
||||
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||
config = configModule.config;
|
||||
originalRoutingWeights = { ...config.routingWeights };
|
||||
originalRoutingFallbackUnitCost = config.routingFallbackUnitCost;
|
||||
@@ -59,11 +61,13 @@ describe('TokenRouter selection scoring', () => {
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
invalidateTokenRouterCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
config.routingWeights = { ...originalRoutingWeights };
|
||||
config.routingFallbackUnitCost = originalRoutingFallbackUnitCost;
|
||||
invalidateTokenRouterCache();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ describe('TokenRouter site status guard', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -23,6 +24,7 @@ describe('TokenRouter site status guard', () => {
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
TokenRouter = tokenRouterModule.TokenRouter;
|
||||
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -33,9 +35,11 @@ describe('TokenRouter site status guard', () => {
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
invalidateTokenRouterCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
invalidateTokenRouterCache();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
|
||||
+971
-881
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user