mirror of
https://github.com/cita-777/metapi.git
synced 2026-11-01 03:53:43 +08:00
490 lines
17 KiB
TypeScript
490 lines
17 KiB
TypeScript
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 { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js';
|
|
import { invalidateTokenRouterCache } from './tokenRouter.js';
|
|
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
|
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
|
|
|
|
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
|
|
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
|
const MODEL_REFRESH_BATCH_SIZE = 3;
|
|
|
|
type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown';
|
|
|
|
function classifyModelDiscoveryError(message: string): ModelRefreshErrorCode {
|
|
const lowered = message.toLowerCase();
|
|
if (lowered.includes('timeout') || lowered.includes('timed out') || lowered.includes('请求超时')) return 'timeout';
|
|
if (lowered.includes('http 401') || lowered.includes('http 403')
|
|
|| lowered.includes('unauthorized') || lowered.includes('invalid')
|
|
|| lowered.includes('无权') || lowered.includes('未提供令牌')) return 'unauthorized';
|
|
return 'unknown';
|
|
}
|
|
|
|
function buildModelFailureMessage(code: ModelRefreshErrorCode, fallback?: string) {
|
|
if (code === 'timeout') return '模型获取失败(请求超时)';
|
|
if (code === 'unauthorized') return '模型获取失败,API Key 已无效';
|
|
if (code === 'empty_models') return '模型获取失败:未获取到可用模型';
|
|
return fallback || '模型获取失败';
|
|
}
|
|
|
|
function isSiteDisabled(status?: string | null): boolean {
|
|
return (status || 'active') === 'disabled';
|
|
}
|
|
|
|
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
|
|
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
|
|
if (explicit && explicit !== 'auto') return explicit === 'apikey';
|
|
return !(account.accessToken || '').trim();
|
|
}
|
|
|
|
function normalizeModels(models: string[]): string[] {
|
|
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 = await 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,
|
|
status: 'failed',
|
|
errorCode: 'account_not_found',
|
|
errorMessage: '账号不存在',
|
|
modelCount: 0,
|
|
modelsPreview: [],
|
|
reason: 'account_not_found',
|
|
};
|
|
}
|
|
|
|
const account = row.accounts;
|
|
const site = row.sites;
|
|
const adapter = getAdapter(site.platform);
|
|
|
|
const accountTokens = await db.select()
|
|
.from(schema.accountTokens)
|
|
.where(eq(schema.accountTokens.accountId, accountId))
|
|
.all();
|
|
|
|
await db.delete(schema.modelAvailability)
|
|
.where(eq(schema.modelAvailability.accountId, accountId))
|
|
.run();
|
|
|
|
for (const token of accountTokens) {
|
|
await db.delete(schema.tokenModelAvailability)
|
|
.where(eq(schema.tokenModelAvailability.tokenId, token.id))
|
|
.run();
|
|
}
|
|
|
|
if (isSiteDisabled(site.status)) {
|
|
return {
|
|
accountId,
|
|
refreshed: false,
|
|
status: 'skipped',
|
|
errorCode: 'site_disabled',
|
|
errorMessage: '站点已禁用',
|
|
modelCount: 0,
|
|
modelsPreview: [],
|
|
reason: 'site_disabled',
|
|
};
|
|
}
|
|
|
|
if (!adapter || account.status !== 'active') {
|
|
return {
|
|
accountId,
|
|
refreshed: false,
|
|
status: 'skipped',
|
|
errorCode: 'adapter_or_status',
|
|
errorMessage: '平台不可用或账号未激活',
|
|
modelCount: 0,
|
|
modelsPreview: [],
|
|
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' });
|
|
await db.update(schema.accounts).set({
|
|
apiToken: discoveredApiToken,
|
|
updatedAt: new Date().toISOString(),
|
|
}).where(eq(schema.accounts.id, account.id)).run();
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
let enabledTokens = await 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 (!isApiKeyConnection(account) && enabledTokens.length === 0) {
|
|
const fallback = discoveredApiToken || account.apiToken || null;
|
|
if (fallback) {
|
|
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
|
|
enabledTokens = await 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 failureMessages: string[] = [];
|
|
const recordFailure = (err: unknown) => {
|
|
const message = (err as { message?: string })?.message || String(err || '');
|
|
if (message) failureMessages.push(message);
|
|
};
|
|
|
|
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 (err) {
|
|
recordFailure(err);
|
|
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 (err) {
|
|
recordFailure(err);
|
|
models = [];
|
|
}
|
|
|
|
if (models.length === 0) continue;
|
|
|
|
const latencyMs = Date.now() - startedAt;
|
|
const checkedAt = new Date().toISOString();
|
|
|
|
await 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 firstMessage = failureMessages[0] || '';
|
|
const errorCode = firstMessage ? classifyModelDiscoveryError(firstMessage) : 'empty_models';
|
|
const errorMessage = buildModelFailureMessage(errorCode, firstMessage);
|
|
await setAccountRuntimeHealth(account.id, {
|
|
state: 'unhealthy',
|
|
reason: errorMessage,
|
|
source: 'model-discovery',
|
|
checkedAt: new Date().toISOString(),
|
|
});
|
|
return {
|
|
accountId,
|
|
refreshed: true,
|
|
status: 'failed',
|
|
errorCode,
|
|
errorMessage,
|
|
modelCount: 0,
|
|
modelsPreview: [],
|
|
tokenScanned: scannedTokenCount,
|
|
discoveredByCredential,
|
|
discoveredApiToken: !!discoveredApiToken,
|
|
};
|
|
}
|
|
|
|
const checkedAt = new Date().toISOString();
|
|
await db.insert(schema.modelAvailability).values(
|
|
Array.from(accountModels).map((modelName) => ({
|
|
accountId: account.id,
|
|
modelName,
|
|
available: true,
|
|
latencyMs: modelLatency.get(modelName) ?? null,
|
|
checkedAt,
|
|
})),
|
|
).run();
|
|
|
|
await setAccountRuntimeHealth(account.id, {
|
|
state: 'healthy',
|
|
reason: '模型探测成功',
|
|
source: 'model-discovery',
|
|
checkedAt,
|
|
});
|
|
|
|
const modelsPreview = Array.from(accountModels).slice(0, 10);
|
|
return {
|
|
accountId,
|
|
refreshed: true,
|
|
status: 'success',
|
|
errorCode: null,
|
|
errorMessage: '',
|
|
modelCount: accountModels.size,
|
|
modelsPreview,
|
|
tokenScanned: scannedTokenCount,
|
|
discoveredByCredential,
|
|
discoveredApiToken: !!discoveredApiToken,
|
|
};
|
|
}
|
|
|
|
async function refreshModelsForAllActiveAccounts() {
|
|
const accounts = await db.select({ id: schema.accounts.id }).from(schema.accounts)
|
|
.where(eq(schema.accounts.status, 'active'))
|
|
.all();
|
|
|
|
const results: any[] = [];
|
|
for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) {
|
|
const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE);
|
|
const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id)));
|
|
results.push(...batchResults);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
export async function rebuildTokenRoutesFromAvailability() {
|
|
const tokenRows = await db.select().from(schema.tokenModelAvailability)
|
|
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
|
|
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
|
|
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
|
.where(
|
|
and(
|
|
eq(schema.tokenModelAvailability.available, true),
|
|
eq(schema.accountTokens.enabled, true),
|
|
eq(schema.accounts.status, 'active'),
|
|
eq(schema.sites.status, 'active'),
|
|
),
|
|
)
|
|
.all();
|
|
|
|
const accountRows = await db.select().from(schema.modelAvailability)
|
|
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
|
|
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
|
.where(
|
|
and(
|
|
eq(schema.modelAvailability.available, true),
|
|
eq(schema.accounts.status, 'active'),
|
|
eq(schema.sites.status, 'active'),
|
|
),
|
|
)
|
|
.all();
|
|
|
|
const modelCandidates = new Map<string, Map<string, { accountId: number; tokenId: number | null }>>();
|
|
const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null) => {
|
|
const modelName = (modelNameRaw || '').trim();
|
|
if (!modelName) return;
|
|
if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map());
|
|
const candidateKey = `${accountId}:${tokenId ?? 'account'}`;
|
|
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
|
|
};
|
|
|
|
for (const row of tokenRows) {
|
|
addModelCandidate(row.token_model_availability.modelName, row.accounts.id, row.account_tokens.id);
|
|
}
|
|
|
|
for (const row of accountRows) {
|
|
if (!isApiKeyConnection(row.accounts)) continue;
|
|
if (!(row.accounts.apiToken || '').trim()) continue;
|
|
addModelCandidate(row.model_availability.modelName, row.accounts.id, null);
|
|
}
|
|
|
|
const routes = await db.select().from(schema.tokenRoutes).all();
|
|
const channels = await db.select().from(schema.routeChannels).all();
|
|
|
|
let createdRoutes = 0;
|
|
let createdChannels = 0;
|
|
let removedChannels = 0;
|
|
let removedRoutes = 0;
|
|
|
|
for (const [modelName, candidateMap] of modelCandidates.entries()) {
|
|
let route = routes.find((r) => r.modelPattern === modelName);
|
|
if (!route) {
|
|
const inserted = await db.insert(schema.tokenRoutes).values({
|
|
modelPattern: modelName,
|
|
enabled: true,
|
|
}).run();
|
|
const insertedId = Number(inserted.lastInsertRowid || 0);
|
|
route = insertedId > 0
|
|
? await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, insertedId)).get()
|
|
: undefined;
|
|
if (!route) continue;
|
|
routes.push(route);
|
|
createdRoutes++;
|
|
}
|
|
|
|
const routeChannels = channels.filter((channel) => channel.routeId === route.id);
|
|
const desiredKeys = new Set(Array.from(candidateMap.keys()));
|
|
|
|
for (const [candidateKey, candidate] of candidateMap.entries()) {
|
|
const exists = routeChannels.some((channel) => (
|
|
channel.accountId === candidate.accountId
|
|
&& (channel.tokenId ?? null) === candidate.tokenId
|
|
));
|
|
if (exists) continue;
|
|
|
|
const inserted = await db.insert(schema.routeChannels).values({
|
|
routeId: route.id,
|
|
accountId: candidate.accountId,
|
|
tokenId: candidate.tokenId,
|
|
priority: 0,
|
|
weight: 10,
|
|
enabled: true,
|
|
manualOverride: false,
|
|
}).run();
|
|
const insertedId = Number(inserted.lastInsertRowid || 0);
|
|
if (insertedId <= 0) continue;
|
|
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, insertedId)).get();
|
|
if (!created) continue;
|
|
channels.push(created);
|
|
createdChannels++;
|
|
desiredKeys.add(candidateKey);
|
|
}
|
|
|
|
for (const channel of routeChannels) {
|
|
const channelKey = `${channel.accountId}:${channel.tokenId ?? 'account'}`;
|
|
if (desiredKeys.has(channelKey)) {
|
|
continue;
|
|
}
|
|
|
|
if (!channel.tokenId) {
|
|
const preferred = await getPreferredAccountToken(channel.accountId);
|
|
if (preferred && desiredKeys.has(`${channel.accountId}:${preferred.id}`)) {
|
|
await db.update(schema.routeChannels)
|
|
.set({ tokenId: preferred.id })
|
|
.where(eq(schema.routeChannels.id, channel.id))
|
|
.run();
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (!channel.manualOverride) {
|
|
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run();
|
|
removedChannels++;
|
|
}
|
|
}
|
|
}
|
|
|
|
const latestModelNames = new Set<string>(Array.from(modelCandidates.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 = (await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run()).changes;
|
|
if (deleted > 0) {
|
|
removedRoutes += deleted;
|
|
}
|
|
}
|
|
|
|
if (createdRoutes > 0 || createdChannels > 0 || removedChannels > 0 || removedRoutes > 0) {
|
|
await clearAllRouteDecisionSnapshots();
|
|
}
|
|
|
|
invalidateTokenRouterCache();
|
|
|
|
return {
|
|
models: modelCandidates.size,
|
|
createdRoutes,
|
|
createdChannels,
|
|
removedChannels,
|
|
removedRoutes,
|
|
};
|
|
}
|
|
|
|
export async function refreshModelsAndRebuildRoutes() {
|
|
const refresh = await refreshModelsForAllActiveAccounts();
|
|
const rebuild = await rebuildTokenRoutesFromAvailability();
|
|
return { refresh, rebuild };
|
|
}
|