import { eq, inArray } from 'drizzle-orm'; import { minimatch } from 'minimatch'; import { db, schema } from '../db/index.js'; import { config } from '../config.js'; import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js'; import { DEFAULT_ROUTE_ROUTING_STRATEGY, normalizeRouteRoutingStrategy, type RouteRoutingStrategy, } from './routeRoutingStrategy.js'; import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js'; import { isUsableAccountToken } from './accountTokenService.js'; import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js'; interface RouteMatch { route: RouteRow; channels: Array<{ channel: typeof schema.routeChannels.$inferSelect; account: typeof schema.accounts.$inferSelect; site: typeof schema.sites.$inferSelect; token: typeof schema.accountTokens.$inferSelect | null; }>; } type RouteChannelCandidate = RouteMatch['channels'][number]; interface SelectedChannel { channel: typeof schema.routeChannels.$inferSelect; account: typeof schema.accounts.$inferSelect; site: typeof schema.sites.$inferSelect; token: typeof schema.accountTokens.$inferSelect | null; tokenValue: string; tokenName: string; actualModel: string; } type FailureAwareChannel = { failCount?: number | null; lastFailAt?: string | null; }; const FAILURE_BACKOFF_BASE_SEC = 15; const MIN_EFFECTIVE_UNIT_COST = 1e-6; const ROUND_ROBIN_FAILURE_THRESHOLD = 3; const ROUND_ROBIN_COOLDOWN_LEVELS_SEC = [0, 10 * 60, 60 * 60, 24 * 60 * 60] as const; function fibonacciNumber(index: number): number { if (index <= 2) return 1; let prev = 1; let current = 1; for (let i = 3; i <= index; i += 1) { const next = prev + current; prev = current; current = next; } return current; } function resolveFailureBackoffSec(failCount?: number | null): number { const normalizedFailCount = Math.max(1, Math.trunc(failCount ?? 0)); return FAILURE_BACKOFF_BASE_SEC * fibonacciNumber(normalizedFailCount); } function resolveRoundRobinCooldownSec(level: number): number { const normalizedLevel = Math.max(0, Math.min(ROUND_ROBIN_COOLDOWN_LEVELS_SEC.length - 1, Math.trunc(level))); return ROUND_ROBIN_COOLDOWN_LEVELS_SEC[normalizedLevel] ?? 0; } type RouteMode = 'pattern' | 'explicit_group'; type RouteRow = typeof schema.tokenRoutes.$inferSelect & { routeMode: RouteMode; sourceRouteIds: number[]; }; 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(); 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(); } async function loadEnabledRoutes(nowMs = Date.now()): Promise { if (isCacheFresh(routeCacheSnapshot.loadedAt, nowMs)) { return routeCacheSnapshot.routes; } const rawRoutes = await db.select().from(schema.tokenRoutes) .where(eq(schema.tokenRoutes.enabled, true)) .all(); const explicitGroupRouteIds = rawRoutes .filter((route) => normalizeRouteMode(route.routeMode) === 'explicit_group') .map((route) => route.id); const sourceRows = explicitGroupRouteIds.length > 0 ? await db.select().from(schema.routeGroupSources) .where(inArray(schema.routeGroupSources.groupRouteId, explicitGroupRouteIds)) .all() : []; const sourceIdsByRouteId = new Map(); for (const row of sourceRows) { if (!sourceIdsByRouteId.has(row.groupRouteId)) { sourceIdsByRouteId.set(row.groupRouteId, []); } sourceIdsByRouteId.get(row.groupRouteId)!.push(row.sourceRouteId); } const routes = rawRoutes.map((route) => ({ ...route, routeMode: normalizeRouteMode(route.routeMode), sourceRouteIds: Array.from(new Set(sourceIdsByRouteId.get(route.id) ?? [])), })); routeCacheSnapshot = { loadedAt: nowMs, routes, }; return routes; } async function loadRouteMatch(route: RouteRow, nowMs = Date.now()): Promise { const cached = routeMatchCache.get(route.id); if (cached && isCacheFresh(cached.loadedAt, nowMs)) { return cached.match; } const routeIds = (() => { if (!isExplicitGroupRoute(route)) { return [route.id]; } return Array.from(new Set(route.sourceRouteIds.filter((routeId) => Number.isFinite(routeId) && routeId > 0))); })(); const enabledSourceRouteIds = isExplicitGroupRoute(route) ? (await loadEnabledRoutes(nowMs)) .filter((item) => ( routeIds.includes(item.id) && !isExplicitGroupRoute(item) && isExactRouteModelPattern(item.modelPattern) )) .map((item) => item.id) : routeIds; const channels = enabledSourceRouteIds.length > 0 ? await 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(inArray(schema.routeChannels.routeId, enabledSourceRouteIds)) .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 { return (status || 'active') === 'disabled'; } export function isChannelRecentlyFailed( channel: FailureAwareChannel, nowMs = Date.now(), avoidSec = resolveFailureBackoffSec(channel.failCount), ): boolean { if (avoidSec <= 0) return false; if ((channel.failCount ?? 0) <= 0) return false; if (!channel.lastFailAt) return false; const failTs = Date.parse(channel.lastFailAt); if (Number.isNaN(failTs)) return false; return nowMs - failTs < avoidSec * 1000; } export function filterRecentlyFailedCandidates( candidates: T[], nowMs = Date.now(), avoidSec?: number, ): T[] { if (candidates.length <= 1) return candidates; if (avoidSec == null || avoidSec <= 0) return candidates; const healthy = candidates.filter((candidate) => !isChannelRecentlyFailed(candidate.channel, nowMs, avoidSec)); // If all channels failed recently, keep them all and let weight/random decide. return healthy.length > 0 ? healthy : candidates; } export interface RouteDecisionCandidate { channelId: number; accountId: number; username: string; siteName: string; tokenName: string; priority: number; weight: number; eligible: boolean; recentlyFailed: boolean; avoidedByRecentFailure: boolean; probability: number; reason: string; } export interface RouteDecisionExplanation { requestedModel: string; actualModel: string; matched: boolean; routeId?: number; modelPattern?: string; selectedChannelId?: number; selectedAccountId?: number; selectedLabel?: string; summary: string[]; candidates: RouteDecisionCandidate[]; } const DEFAULT_DOWNSTREAM_POLICY: DownstreamRoutingPolicy = EMPTY_DOWNSTREAM_ROUTING_POLICY; type ExplainSelectionOptions = { excludeChannelIds?: number[]; bypassSourceModelCheck?: boolean; useChannelSourceModelForCost?: boolean; downstreamPolicy?: DownstreamRoutingPolicy; }; type PricingReferenceRefreshOptions = { useChannelSourceModelForCost?: boolean; downstreamPolicy?: DownstreamRoutingPolicy; refreshedKeys?: Set; }; type CandidateEligibilityOptions = { requestedModel: string; bypassSourceModelCheck?: boolean; excludeChannelIds?: number[]; nowIso?: string; }; type CostSignal = { unitCost: number; source: 'observed' | 'configured' | 'catalog' | 'fallback'; }; export function isRegexModelPattern(pattern: string): boolean { return pattern.trim().toLowerCase().startsWith('re:'); } export function parseRegexModelPattern(pattern: string): RegExp | null { if (!isRegexModelPattern(pattern)) return null; const body = pattern.trim().slice(3).trim(); if (!body) return null; try { return new RegExp(body); } catch { return null; } } export function matchesModelPattern(model: string, pattern: string): boolean { const normalizedPattern = (pattern || '').trim(); if (!normalizedPattern) return false; if (normalizedPattern === model) return true; if (isRegexModelPattern(normalizedPattern)) { const re = parseRegexModelPattern(normalizedPattern); return !!re && re.test(model); } return minimatch(model, normalizedPattern); } function isExactRouteModelPattern(pattern: string): boolean { const normalizedPattern = (pattern || '').trim(); if (!normalizedPattern) return false; if (isRegexModelPattern(normalizedPattern)) return false; return !/[\*\?\[]/.test(normalizedPattern); } function normalizeRouteMode(routeMode: string | null | undefined): RouteMode { return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern'; } function isExplicitGroupRoute(route: Pick | Pick): boolean { return normalizeRouteMode(route.routeMode) === 'explicit_group'; } function normalizeRouteDisplayName(displayName: string | null | undefined): string { return (displayName || '').trim(); } function isRouteDisplayNameMatch(model: string, displayName: string | null | undefined): boolean { const alias = normalizeRouteDisplayName(displayName); return !!alias && alias === model; } function matchesRouteRequestModel(model: string, route: RouteRow): boolean { if (isExplicitGroupRoute(route)) { return isRouteDisplayNameMatch(model, route.displayName); } return matchesModelPattern(model, route.modelPattern) || isRouteDisplayNameMatch(model, route.displayName); } function getExposedModelNameForRoute(route: RouteRow): string { return normalizeRouteDisplayName(route.displayName) || route.modelPattern; } function hasCustomDisplayName(route: Pick): boolean { const displayName = normalizeRouteDisplayName(route.displayName); const modelPattern = (route.modelPattern || '').trim(); return !!displayName && displayName !== modelPattern; } function buildVisibleEnabledRoutes(routes: RouteRow[]): RouteRow[] { const exactModelNames = new Set( routes .filter((route) => !isExplicitGroupRoute(route) && isExactRouteModelPattern(route.modelPattern)) .map((route) => (route.modelPattern || '').trim()) .filter(Boolean), ); const coveringGroups = routes.filter((route) => ( route.enabled && ( (isExplicitGroupRoute(route) && normalizeRouteDisplayName(route.displayName).length > 0 && route.sourceRouteIds.length > 0) || (!isExplicitGroupRoute(route) && !isExactRouteModelPattern(route.modelPattern) && hasCustomDisplayName(route)) ) )); if (coveringGroups.length === 0) return routes; return routes.filter((route) => { if (isExplicitGroupRoute(route)) { return normalizeRouteDisplayName(route.displayName).length > 0; } if (!isExactRouteModelPattern(route.modelPattern)) return true; if (hasCustomDisplayName(route)) return true; const exactModel = (route.modelPattern || '').trim(); if (!exactModel) return true; return !coveringGroups.some((groupRoute) => { if (groupRoute.id === route.id) return false; const groupDisplayName = normalizeRouteDisplayName(groupRoute.displayName); if (!groupDisplayName || exactModelNames.has(groupDisplayName)) return false; if (isExplicitGroupRoute(groupRoute)) { return groupRoute.sourceRouteIds.includes(route.id); } return matchesModelPattern(exactModel, groupRoute.modelPattern); }); }); } function normalizeModelAlias(modelName: string): string { const normalized = (modelName || '').trim().toLowerCase(); if (!normalized) return ''; const slashIndex = normalized.lastIndexOf('/'); if (slashIndex >= 0 && slashIndex < normalized.length - 1) { return normalized.slice(slashIndex + 1); } return normalized; } function isModelAliasEquivalent(left: string, right: string): boolean { const a = normalizeModelAlias(left); const b = normalizeModelAlias(right); return !!a && !!b && a === b; } function channelSupportsRequestedModel(channelSourceModel: string | null | undefined, requestedModel: string): boolean { const source = (channelSourceModel || '').trim(); if (!source) return true; if (source === requestedModel) return true; if (isModelAliasEquivalent(source, requestedModel)) return true; if (matchesModelPattern(requestedModel, source)) return true; return false; } function isModelAllowedByDownstreamPolicy(requestedModel: string, policy: DownstreamRoutingPolicy): boolean { const supportedPatterns = Array.isArray(policy.supportedModels) ? policy.supportedModels : []; const matchedSupportedPattern = supportedPatterns.some((pattern) => matchesModelPattern(requestedModel, pattern)); if (matchedSupportedPattern) return true; if (policy.allowedRouteIds.length > 0) return true; return supportedPatterns.length === 0; } function resolveMappedModel(requestedModel: string, modelMapping?: string | null): string { if (!modelMapping) return requestedModel; let parsed: unknown; try { parsed = JSON.parse(modelMapping); } catch { return requestedModel; } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return requestedModel; } const entries = Object.entries(parsed as Record) .filter(([, value]) => typeof value === 'string' && value.trim().length > 0) as Array<[string, string]>; const exact = entries.find(([pattern]) => pattern === requestedModel); if (exact) return exact[1].trim(); for (const [pattern, target] of entries) { if (matchesModelPattern(requestedModel, pattern)) { return target.trim(); } } return requestedModel; } function normalizeChannelSourceModel(channelSourceModel: string | null | undefined): string { return (channelSourceModel || '').trim(); } function resolveActualModelForSelectedChannel( requestedModel: string, route: RouteRow, mappedModel: string, channelSourceModel: string | null | undefined, ): string { const sourceModel = normalizeChannelSourceModel(channelSourceModel); if (isRouteDisplayNameMatch(requestedModel, route.displayName) && sourceModel) { return sourceModel; } return mappedModel; } function resolveRouteStrategy(route: RouteRow): RouteRoutingStrategy { return normalizeRouteRoutingStrategy(route.routingStrategy); } function parseIsoTimeMs(value?: string | null): number | null { if (!value) return null; const parsed = Date.parse(value); return Number.isNaN(parsed) ? null : parsed; } function compareNullableTimeAsc(left?: string | null, right?: string | null): number { const leftMs = parseIsoTimeMs(left); const rightMs = parseIsoTimeMs(right); if (leftMs == null && rightMs == null) return 0; if (leftMs == null) return -1; if (rightMs == null) return 1; return leftMs - rightMs; } function resolveEffectiveUnitCost(candidate: RouteChannelCandidate, modelName: string): CostSignal { const successCount = Math.max(0, candidate.channel.successCount ?? 0); const totalCost = Math.max(0, candidate.channel.totalCost ?? 0); const configured = candidate.account.unitCost ?? null; if (successCount > 0 && totalCost > 0) { return { unitCost: Math.max(totalCost / successCount, MIN_EFFECTIVE_UNIT_COST), source: 'observed', }; } if (typeof configured === 'number' && Number.isFinite(configured) && configured > 0) { return { unitCost: Math.max(configured, MIN_EFFECTIVE_UNIT_COST), source: 'configured', }; } const catalogCost = getCachedModelRoutingReferenceCost({ siteId: candidate.site.id, accountId: candidate.account.id, modelName, }); if (typeof catalogCost === 'number' && Number.isFinite(catalogCost) && catalogCost > 0) { return { unitCost: Math.max(catalogCost, MIN_EFFECTIVE_UNIT_COST), source: 'catalog', }; } return { unitCost: Math.max(config.routingFallbackUnitCost || 1, MIN_EFFECTIVE_UNIT_COST), source: 'fallback', }; } function isExplicitTokenChannel(candidate: RouteChannelCandidate): boolean { return typeof candidate.channel.tokenId === 'number' && candidate.channel.tokenId > 0; } export class TokenRouter { /** * Find matching route and select a channel for the given model. * Returns null if no route/channel available. */ async selectChannel(requestedModel: string, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): Promise { if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; const match = await this.findRoute(requestedModel, downstreamPolicy); if (!match) return null; return await this.selectFromMatch(match, requestedModel, downstreamPolicy); } async previewSelectedChannel( requestedModel: string, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, ): Promise { if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; const match = await this.findRoute(requestedModel, downstreamPolicy); if (!match) return null; return await this.selectFromMatch(match, requestedModel, downstreamPolicy, [], false); } /** * Select next channel for failover (exclude already-tried channels). */ async selectNextChannel( requestedModel: string, excludeChannelIds: number[], downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, ): Promise { if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; const match = await this.findRoute(requestedModel, downstreamPolicy); if (!match) return null; return await this.selectFromMatch(match, requestedModel, downstreamPolicy, excludeChannelIds); } async explainSelection( requestedModel: string, excludeChannelIds: number[] = [], downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, ): Promise { const match = await this.findRoute(requestedModel, downstreamPolicy); return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy }); } async explainSelectionForRoute( routeId: number, requestedModel: string, excludeChannelIds: number[] = [], downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, ): Promise { const match = await this.findRouteById(routeId, downstreamPolicy); return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy }); } async explainSelectionRouteWide(routeId: number, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): Promise { const match = await this.findRouteById(routeId, downstreamPolicy); const fallbackRequestedModel = match?.route.modelPattern || `route:${routeId}`; return this.explainSelectionFromMatch(match, fallbackRequestedModel, { bypassSourceModelCheck: true, useChannelSourceModelForCost: true, downstreamPolicy, }); } async refreshPricingReferenceCosts( requestedModel: string, options: PricingReferenceRefreshOptions = {}, ): Promise { const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY; const match = await this.findRoute(requestedModel, downstreamPolicy); await this.refreshPricingReferenceCostsForMatch(match, requestedModel, options); } async refreshPricingReferenceCostsForRoute( routeId: number, requestedModel: string, options: PricingReferenceRefreshOptions = {}, ): Promise { const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY; const match = await this.findRouteById(routeId, downstreamPolicy); await this.refreshPricingReferenceCostsForMatch(match, requestedModel, options); } async refreshRouteWidePricingReferenceCosts( routeId: number, options: Omit = {}, ): Promise { const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY; const match = await this.findRouteById(routeId, downstreamPolicy); const requestedModel = match?.route.modelPattern || `route:${routeId}`; await this.refreshPricingReferenceCostsForMatch(match, requestedModel, { ...options, useChannelSourceModelForCost: true, }); } private explainSelectionFromMatch( match: RouteMatch | null, requestedModel: string, options: ExplainSelectionOptions = {}, ): RouteDecisionExplanation { const excludeChannelIds = options.excludeChannelIds ?? []; const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY; if (!match) { return { requestedModel, actualModel: requestedModel, matched: false, summary: ['未匹配到启用的路由'], candidates: [], }; } const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName); const bypassSourceModelCheck = (options.bypassSourceModelCheck ?? false) || requestedByDisplayName; const useChannelSourceModelForCost = (options.useChannelSourceModelForCost ?? false) || requestedByDisplayName; const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping); const routeStrategy = resolveRouteStrategy(match.route); const nowIso = new Date().toISOString(); const nowMs = Date.now(); const summary: string[] = [ `命中路由:${match.route.modelPattern}`, routeStrategy === 'round_robin' ? '路由策略:轮询' : '路由策略:按权重随机', ]; if (requestedByDisplayName) { summary.push(`按显示名命中:${normalizeRouteDisplayName(match.route.displayName)}`); summary.push('显示名仅用于聚合展示,实际转发模型按选中通道来源模型决定'); } const availableByPriority = new Map(); const candidates: RouteDecisionCandidate[] = []; const candidateMap = new Map(); for (const row of match.channels) { const reasonParts = this.getCandidateEligibilityReasons(row, { requestedModel, bypassSourceModelCheck, excludeChannelIds, nowIso, }); const recentlyFailed = routeStrategy === DEFAULT_ROUTE_ROUTING_STRATEGY ? isChannelRecentlyFailed(row.channel, nowMs) : false; const eligible = reasonParts.length === 0; const candidate: RouteDecisionCandidate = { channelId: row.channel.id, accountId: row.account.id, username: row.account.username || `account-${row.account.id}`, siteName: row.site.name || 'unknown', tokenName: row.token?.name || 'default', priority: row.channel.priority ?? 0, weight: row.channel.weight ?? 10, eligible, recentlyFailed, avoidedByRecentFailure: false, probability: 0, reason: eligible ? '可用' : reasonParts.join('、'), }; candidates.push(candidate); candidateMap.set(candidate.channelId, candidate); if (eligible) { const priority = row.channel.priority ?? 0; if (!availableByPriority.has(priority)) availableByPriority.set(priority, []); availableByPriority.get(priority)!.push(row); } } if (availableByPriority.size === 0) { summary.push('没有可用通道(全部被禁用、站点不可用、冷却或令牌不可用)'); return { requestedModel, actualModel: mappedModel, matched: true, routeId: match.route.id, modelPattern: match.route.modelPattern, summary, candidates, }; } if (routeStrategy === 'round_robin') { const ordered = this.getRoundRobinCandidates(match.channels.filter((row) => { const target = candidateMap.get(row.channel.id); return !!target?.eligible; })); let selected: RouteChannelCandidate | null = null; for (let index = 0; index < ordered.length; index += 1) { const target = candidateMap.get(ordered[index].channel.id); if (!target || !target.eligible) continue; target.probability = index === 0 ? 100 : 0; target.reason = index === 0 ? `轮询命中(全局第 1 / ${ordered.length} 位,忽略优先级)` : `轮询排队中(全局第 ${index + 1} / ${ordered.length} 位,忽略优先级)`; if (index === 0) { selected = ordered[index]; } } if (!selected) { summary.push('本次未选出通道'); return { requestedModel, actualModel: mappedModel, matched: true, routeId: match.route.id, modelPattern: match.route.modelPattern, summary, candidates, }; } const selectedChannel = candidateMap.get(selected.channel.id); const selectedLabel = selectedChannel ? `${selectedChannel.username} @ ${selectedChannel.siteName} / ${selectedChannel.tokenName}` : `channel-${selected.channel.id}`; const actualModel = resolveActualModelForSelectedChannel( requestedModel, match.route, mappedModel, selected.channel.sourceModel, ); summary.push(`全局轮询:可用 ${ordered.length},忽略优先级`); summary.push(`最终选择:${selectedLabel}`); if (actualModel !== mappedModel) { summary.push(`实际转发模型:${actualModel}`); } return { requestedModel, actualModel, matched: true, routeId: match.route.id, modelPattern: match.route.modelPattern, selectedChannelId: selected.channel.id, selectedAccountId: selected.account.id, selectedLabel, summary, candidates, }; } const sortedPriorities = Array.from(availableByPriority.keys()).sort((a, b) => a - b); let selected: RouteChannelCandidate | null = null; let selectedPriority = 0; for (const priority of sortedPriorities) { const rawLayer = availableByPriority.get(priority) ?? []; if (rawLayer.length === 0) continue; const filteredLayer = filterRecentlyFailedCandidates(rawLayer, nowMs); const avoided = rawLayer.filter((row) => !filteredLayer.some((item) => item.channel.id === row.channel.id)); if (avoided.length > 0) { for (const row of avoided) { const target = candidateMap.get(row.channel.id); if (!target) continue; target.avoidedByRecentFailure = true; target.reason = `最近失败,优先避让(${resolveFailureBackoffSec(row.channel.failCount)} 秒窗口)`; } } const weighted = this.calculateWeightedSelection( filteredLayer, useChannelSourceModelForCost ? (candidate) => (normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel) : mappedModel, downstreamPolicy, ); for (const detail of weighted.details) { const target = candidateMap.get(detail.candidate.channel.id); if (!target) continue; target.probability = Number((detail.probability * 100).toFixed(2)); if (target.eligible && !target.avoidedByRecentFailure) { target.reason = detail.reason; } } if (!weighted.selected) continue; selected = weighted.selected; selectedPriority = priority; summary.push( avoided.length > 0 ? `优先级 P${priority}:可用 ${rawLayer.length},因最近失败避让 ${avoided.length}` : `优先级 P${priority}:可用 ${rawLayer.length}`, ); break; } if (!selected) { summary.push('本次未选出通道'); return { requestedModel, actualModel: mappedModel, matched: true, routeId: match.route.id, modelPattern: match.route.modelPattern, summary, candidates, }; } const selectedChannel = candidateMap.get(selected.channel.id); const selectedLabel = selectedChannel ? `${selectedChannel.username} @ ${selectedChannel.siteName} / ${selectedChannel.tokenName}` : `channel-${selected.channel.id}`; const actualModel = resolveActualModelForSelectedChannel( requestedModel, match.route, mappedModel, selected.channel.sourceModel, ); summary.push(`最终选择:${selectedLabel}(P${selectedPriority})`); if (actualModel !== mappedModel) { summary.push(`实际转发模型:${actualModel}`); } return { requestedModel, actualModel, matched: true, routeId: match.route.id, modelPattern: match.route.modelPattern, selectedChannelId: selected.channel.id, selectedAccountId: selected.account.id, selectedLabel, summary, candidates, }; } private async refreshPricingReferenceCostsForMatch( match: RouteMatch | null, requestedModel: string, options: PricingReferenceRefreshOptions = {}, ): Promise { if (!match) return; const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName); const useChannelSourceModelForCost = (options.useChannelSourceModelForCost ?? false) || requestedByDisplayName; const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping); const refreshedKeys = options.refreshedKeys ?? new Set(); await Promise.allSettled(match.channels.map(async (candidate) => { const refreshKey = `${candidate.site.id}:${candidate.account.id}`; if (refreshedKeys.has(refreshKey)) return; refreshedKeys.add(refreshKey); const modelName = useChannelSourceModelForCost ? (normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel) : mappedModel; if (!modelName) return; await refreshModelPricingCatalog({ site: { id: candidate.site.id, url: candidate.site.url, platform: candidate.site.platform, apiKey: candidate.site.apiKey, }, account: { id: candidate.account.id, accessToken: candidate.account.accessToken, apiToken: candidate.account.apiToken, }, modelName, }); })); } /** * Record success for a channel. */ async recordSuccess(channelId: number, latencyMs: number, cost: number) { const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); 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; await db.update(schema.routeChannels).set({ successCount: nextSuccessCount, totalLatencyMs: nextTotalLatencyMs, totalCost: nextTotalCost, lastUsedAt: nowIso, cooldownUntil: null, lastFailAt: null, consecutiveFailCount: 0, cooldownLevel: 0, }).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; channel.consecutiveFailCount = 0; channel.cooldownLevel = 0; }); } /** * Record failure and set cooldown. */ async recordFailure(channelId: number) { const row = await db.select() .from(schema.routeChannels) .innerJoin(schema.tokenRoutes, eq(schema.routeChannels.routeId, schema.tokenRoutes.id)) .where(eq(schema.routeChannels.id, channelId)) .get(); if (!row) return; const ch = row.route_channels; const route = row.token_routes; const nowMs = Date.now(); const nowIso = new Date(nowMs).toISOString(); const failCount = (ch.failCount ?? 0) + 1; const routeStrategy = resolveRouteStrategy(route); let cooldownUntil: string | null = null; let consecutiveFailCount = Math.max(0, ch.consecutiveFailCount ?? 0) + 1; let cooldownLevel = Math.max(0, ch.cooldownLevel ?? 0); if (routeStrategy === 'round_robin') { if (consecutiveFailCount >= ROUND_ROBIN_FAILURE_THRESHOLD) { cooldownLevel = Math.min(cooldownLevel + 1, ROUND_ROBIN_COOLDOWN_LEVELS_SEC.length - 1); const cooldownSec = resolveRoundRobinCooldownSec(cooldownLevel); cooldownUntil = cooldownSec > 0 ? new Date(nowMs + cooldownSec * 1000).toISOString() : null; consecutiveFailCount = 0; } } else { const cooldownSec = resolveFailureBackoffSec(failCount); cooldownUntil = new Date(nowMs + cooldownSec * 1000).toISOString(); consecutiveFailCount = 0; cooldownLevel = 0; } await db.update(schema.routeChannels).set({ failCount, lastFailAt: nowIso, consecutiveFailCount, cooldownLevel, cooldownUntil, }).where(eq(schema.routeChannels.id, channelId)).run(); patchCachedChannel(channelId, (channel) => { channel.failCount = failCount; channel.lastFailAt = nowIso; channel.cooldownUntil = cooldownUntil; channel.consecutiveFailCount = consecutiveFailCount; channel.cooldownLevel = cooldownLevel; }); } /** * Get all available models (aggregated from all routes). */ async getAvailableModels(): Promise { const routes = await loadEnabledRoutes(); const exposed = buildVisibleEnabledRoutes(routes) .map((route) => getExposedModelNameForRoute(route).trim()) .filter((name) => name.length > 0); return Array.from(new Set(exposed)); } // --- Private methods --- private async selectFromMatch( match: RouteMatch, requestedModel: string, downstreamPolicy: DownstreamRoutingPolicy, excludeChannelIds: number[] = [], recordSelection = true, ): Promise { const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping); const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName); const bypassSourceModelCheck = requestedByDisplayName; const routeStrategy = resolveRouteStrategy(match.route); const nowIso = new Date().toISOString(); const nowMs = Date.now(); const available = match.channels.filter((candidate) => ( this.getCandidateEligibilityReasons(candidate, { requestedModel, bypassSourceModelCheck, excludeChannelIds, nowIso, }).length === 0 )); if (available.length === 0) return null; if (routeStrategy === 'round_robin') { const selected = this.selectRoundRobinCandidate(available); if (!selected) return null; const tokenValue = this.resolveChannelTokenValue(selected); if (!tokenValue) return null; if (recordSelection) { await this.recordChannelSelection(selected.channel.id); } const actualModel = resolveActualModelForSelectedChannel( requestedModel, match.route, mappedModel, selected.channel.sourceModel, ); return { ...selected, tokenValue, tokenName: selected.token?.name || 'default', actualModel, }; } const layers = new Map(); for (const candidate of available) { const priority = candidate.channel.priority ?? 0; if (!layers.has(priority)) layers.set(priority, []); layers.get(priority)!.push(candidate); } const sortedPriorities = Array.from(layers.keys()).sort((a, b) => a - b); for (const priority of sortedPriorities) { const rawLayer = layers.get(priority) ?? []; const candidates = filterRecentlyFailedCandidates(rawLayer, nowMs); const selected = this.weightedRandomSelect( candidates, requestedByDisplayName ? (candidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel : mappedModel, downstreamPolicy, ); if (!selected) continue; const tokenValue = this.resolveChannelTokenValue(selected); if (!tokenValue) continue; const actualModel = resolveActualModelForSelectedChannel( requestedModel, match.route, mappedModel, selected.channel.sourceModel, ); return { ...selected, tokenValue, tokenName: selected.token?.name || 'default', actualModel, }; } return null; } private async findRoute(model: string, downstreamPolicy: DownstreamRoutingPolicy): Promise { let routes = await loadEnabledRoutes(); const supportedPatterns = Array.isArray(downstreamPolicy.supportedModels) ? downstreamPolicy.supportedModels : []; const matchedSupportedPattern = supportedPatterns.some((pattern) => matchesModelPattern(model, pattern)); if (downstreamPolicy.allowedRouteIds.length > 0 && !matchedSupportedPattern) { const allowSet = new Set(downstreamPolicy.allowedRouteIds); routes = routes.filter((route) => allowSet.has(route.id)); } const matchedRoute = routes.find((route) => ( !isExplicitGroupRoute(route) && isExactRouteModelPattern(route.modelPattern) && (route.modelPattern || '').trim() === model )) || routes.find((route) => isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName)) || routes.find((route) => !isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName)) || routes.find((route) => !isExplicitGroupRoute(route) && matchesModelPattern(model, route.modelPattern)); if (!matchedRoute) return null; return await this.loadRouteMatch(matchedRoute); } private async findRouteById(routeId: number, downstreamPolicy: DownstreamRoutingPolicy): Promise { if (downstreamPolicy.allowedRouteIds.length > 0 && !downstreamPolicy.allowedRouteIds.includes(routeId)) { return null; } const route = (await loadEnabledRoutes()).find((item) => item.id === routeId); if (!route) return null; return await this.loadRouteMatch(route); } private async loadRouteMatch(route: RouteRow): Promise { return await loadRouteMatch(route); } private resolveChannelTokenValue(candidate: { channel: typeof schema.routeChannels.$inferSelect; account: typeof schema.accounts.$inferSelect; site?: typeof schema.sites.$inferSelect | null; token: typeof schema.accountTokens.$inferSelect | null; }): string | null { if (candidate.channel.tokenId) { if (!candidate.token) return null; if (!isUsableAccountToken(candidate.token)) return null; const token = candidate.token.token?.trim(); return token ? token : null; } const fallback = candidate.account.apiToken?.trim(); if (fallback) return fallback; if (getOauthInfoFromExtraConfig(candidate.account.extraConfig)) { const accessToken = candidate.account.accessToken?.trim(); return accessToken || null; } return null; } private getCandidateEligibilityReasons( candidate: RouteChannelCandidate, options: CandidateEligibilityOptions, ): string[] { const reasonParts: string[] = []; const bypassSourceModelCheck = options.bypassSourceModelCheck ?? false; const excludeChannelIds = options.excludeChannelIds ?? []; const nowIso = options.nowIso ?? new Date().toISOString(); if (!bypassSourceModelCheck && !channelSupportsRequestedModel(candidate.channel.sourceModel, options.requestedModel)) { reasonParts.push(`来源模型不匹配=${candidate.channel.sourceModel || ''}`); } if (!candidate.channel.enabled) reasonParts.push('通道禁用'); if (isExplicitTokenChannel(candidate)) { if (candidate.account.status === 'disabled') { reasonParts.push(`账号状态=${candidate.account.status}`); } } else if (candidate.account.status !== 'active') { reasonParts.push(`账号状态=${candidate.account.status}`); } if (isSiteDisabled(candidate.site.status)) { reasonParts.push(`站点状态=${candidate.site.status || 'disabled'}`); } if (excludeChannelIds.includes(candidate.channel.id)) { reasonParts.push('当前请求已尝试'); } const tokenValue = this.resolveChannelTokenValue(candidate); if (!tokenValue) reasonParts.push('令牌不可用'); if (candidate.channel.cooldownUntil && candidate.channel.cooldownUntil > nowIso) { reasonParts.push('冷却中'); } return reasonParts; } private getRoundRobinCandidates(candidates: RouteChannelCandidate[]): RouteChannelCandidate[] { return [...candidates].sort((left, right) => { const selectionOrder = compareNullableTimeAsc( left.channel.lastSelectedAt || left.channel.lastUsedAt, right.channel.lastSelectedAt || right.channel.lastUsedAt, ); if (selectionOrder !== 0) return selectionOrder; const usedOrder = compareNullableTimeAsc(left.channel.lastUsedAt, right.channel.lastUsedAt); if (usedOrder !== 0) return usedOrder; return (left.channel.id ?? 0) - (right.channel.id ?? 0); }); } private selectRoundRobinCandidate(candidates: RouteChannelCandidate[]): RouteChannelCandidate | null { return this.getRoundRobinCandidates(candidates)[0] ?? null; } private async recordChannelSelection(channelId: number): Promise { const nowIso = new Date().toISOString(); await db.update(schema.routeChannels).set({ lastSelectedAt: nowIso, }).where(eq(schema.routeChannels.id, channelId)).run(); patchCachedChannel(channelId, (channel) => { channel.lastSelectedAt = nowIso; }); } private weightedRandomSelect( candidates: RouteChannelCandidate[], modelName: string | ((candidate: RouteChannelCandidate) => string), downstreamPolicy: DownstreamRoutingPolicy, ) { return this.calculateWeightedSelection(candidates, modelName, downstreamPolicy).selected; } private calculateWeightedSelection( candidates: RouteChannelCandidate[], modelName: string | ((candidate: RouteChannelCandidate) => string), downstreamPolicy: DownstreamRoutingPolicy, ) { if (candidates.length === 0) { return { selected: null as RouteChannelCandidate | null, details: [] as Array<{ candidate: RouteChannelCandidate; probability: number; reason: string }>, }; } if (candidates.length === 1) { return { selected: candidates[0], details: [{ candidate: candidates[0], probability: 1, reason: '唯一可用候选', }], }; } const { baseWeightFactor, valueScoreFactor, costWeight, balanceWeight, usageWeight } = config.routingWeights; const resolveModelName = typeof modelName === 'function' ? modelName : (() => modelName); const effectiveCosts = candidates.map((candidate) => resolveEffectiveUnitCost(candidate, resolveModelName(candidate))); const valueScores = candidates.map((c, i) => { const unitCost = effectiveCosts[i]?.unitCost || 1; const balance = c.account.balance || 0; const totalUsed = (c.channel.successCount ?? 0) + (c.channel.failCount ?? 0); const recentUsage = Math.max(totalUsed, 1); return costWeight * (1 / unitCost) + balanceWeight * balance + usageWeight * (1 / recentUsage); }); const maxVS = Math.max(...valueScores, 0.001); const minVS = Math.min(...valueScores, 0); const range = maxVS - minVS || 1; const normalizedVS = valueScores.map((v) => (v - minVS) / range); const baseContributions = candidates.map((c, i) => { const weight = c.channel.weight ?? 10; return (weight + 10) * (baseWeightFactor + normalizedVS[i] * valueScoreFactor); }); // Avoid over-favoring a site that has many tokens/channels for the same route. // Site-level total contribution remains comparable, then split across its channels. const siteChannelCounts = new Map(); for (const candidate of candidates) { siteChannelCounts.set(candidate.site.id, (siteChannelCounts.get(candidate.site.id) || 0) + 1); } const contributions = candidates.map((candidate, i) => { const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1); let contribution = baseContributions[i] / siteChannels; const downstreamSiteMultiplier = downstreamPolicy.siteWeightMultipliers[candidate.site.id] ?? 1; const normalizedDownstreamSiteMultiplier = (Number.isFinite(downstreamSiteMultiplier) && downstreamSiteMultiplier > 0) ? downstreamSiteMultiplier : 1; const siteGlobalWeight = (Number.isFinite(candidate.site.globalWeight) && (candidate.site.globalWeight || 0) > 0) ? (candidate.site.globalWeight as number) : 1; const combinedSiteWeight = siteGlobalWeight * normalizedDownstreamSiteMultiplier; if (combinedSiteWeight > 0 && Number.isFinite(combinedSiteWeight)) { contribution *= combinedSiteWeight; } // If upstream price is unknown and we are using fallback unit cost, // apply an explicit penalty so raising fallback cost meaningfully lowers probability. if (effectiveCosts[i]?.source === 'fallback') { contribution *= 1 / Math.max(1, effectiveCosts[i]?.unitCost || 1); } return contribution; }); const totalContribution = contributions.reduce((a, b) => a + b, 0); const details = candidates.map((candidate, i) => { const probability = totalContribution > 0 ? contributions[i] / totalContribution : 0; const weight = candidate.channel.weight ?? 10; const cost = effectiveCosts[i]; const costSourceText = cost?.source === 'observed' ? '实测' : (cost?.source === 'configured' ? '配置' : (cost?.source === 'catalog' ? '目录' : '默认')); const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1); const downstreamSiteMultiplier = downstreamPolicy.siteWeightMultipliers[candidate.site.id] ?? 1; const normalizedDownstreamSiteMultiplier = (Number.isFinite(downstreamSiteMultiplier) && downstreamSiteMultiplier > 0) ? downstreamSiteMultiplier : 1; const siteGlobalWeight = (Number.isFinite(candidate.site.globalWeight) && (candidate.site.globalWeight || 0) > 0) ? (candidate.site.globalWeight as number) : 1; const combinedSiteWeight = siteGlobalWeight * normalizedDownstreamSiteMultiplier; return { candidate, probability, reason: `按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},站点权重=${siteGlobalWeight.toFixed(2)}x下游倍率=${normalizedDownstreamSiteMultiplier.toFixed(2)}=${combinedSiteWeight.toFixed(2)},同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)`, }; }); let rand = Math.random() * totalContribution; let selected = candidates[candidates.length - 1]; for (let i = 0; i < candidates.length; i++) { rand -= contributions[i]; if (rand <= 0) { selected = candidates[i]; break; } } return { selected, details }; } } export const tokenRouter = new TokenRouter();