Files
metapi/src/server/services/tokenRouter.ts
T
cita e281e45e84 feat: add comprehensive tests for token candidates and proxy log path metadata
- Implemented tests for the `/api/models/token-candidates` endpoint to verify model availability and token coverage scenarios.
- Added tests for `composeProxyLogMessage` to ensure proper formatting of log messages with downstream and upstream metadata.
- Created utility functions for parsing proxy log path metadata and handling missing token hints.
- Developed tests for the `Sites` and `TokenRoutes` components to validate UI behavior and interactions, including scrolling and group expansion.
- Introduced a custom hook `useAnimatedVisibility` for managing visibility animations in components.
- Enhanced the documentation link for site resources.
2026-03-04 13:46:31 +08:00

884 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { eq } from 'drizzle-orm';
import { minimatch } from 'minimatch';
import { db, schema } from '../db/index.js';
import { config } from '../config.js';
import { getCachedModelRoutingReferenceCost } from './modelPricingService.js';
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
interface RouteMatch {
route: typeof schema.tokenRoutes.$inferSelect;
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 RECENT_FAILURE_AVOID_SEC = 10 * 60;
const MIN_EFFECTIVE_UNIT_COST = 1e-6;
function isSiteDisabled(status?: string | null): boolean {
return (status || 'active') === 'disabled';
}
export function isChannelRecentlyFailed(
channel: FailureAwareChannel,
nowMs = Date.now(),
avoidSec = RECENT_FAILURE_AVOID_SEC,
): 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<T extends { channel: FailureAwareChannel }>(
candidates: T[],
nowMs = Date.now(),
avoidSec = RECENT_FAILURE_AVOID_SEC,
): T[] {
if (candidates.length <= 1) return candidates;
if (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 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 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: typeof schema.tokenRoutes.$inferSelect): boolean {
return matchesModelPattern(model, route.modelPattern) || isRouteDisplayNameMatch(model, route.displayName);
}
function getExposedModelNameForRoute(route: typeof schema.tokenRoutes.$inferSelect): string {
return normalizeRouteDisplayName(route.displayName) || route.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<string, unknown>)
.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: typeof schema.tokenRoutes.$inferSelect,
mappedModel: string,
channelSourceModel: string | null | undefined,
): string {
const sourceModel = normalizeChannelSourceModel(channelSourceModel);
if (isRouteDisplayNameMatch(requestedModel, route.displayName) && sourceModel) {
return sourceModel;
}
return mappedModel;
}
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',
};
}
export class TokenRouter {
/**
* Find matching route and select a channel for the given model.
* Returns null if no route/channel available.
*/
selectChannel(requestedModel: string, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): SelectedChannel | null {
if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null;
const match = this.findRoute(requestedModel, downstreamPolicy);
if (!match) return null;
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
const bypassSourceModelCheck = requestedByDisplayName;
// Filter channels: enabled, not in cooldown, account/site active with token
const nowIso = new Date().toISOString();
const nowMs = Date.now();
const available = match.channels.filter((c) =>
(bypassSourceModelCheck || channelSupportsRequestedModel(c.channel.sourceModel, requestedModel)) &&
c.channel.enabled &&
c.account.status === 'active' &&
!isSiteDisabled(c.site.status) &&
!!this.resolveChannelTokenValue(c) &&
(!c.channel.cooldownUntil || c.channel.cooldownUntil <= nowIso),
);
if (available.length === 0) return null;
// Group by priority
const layers = new Map<number, typeof available>();
for (const c of available) {
const p = c.channel.priority ?? 0;
if (!layers.has(p)) layers.set(p, []);
layers.get(p)!.push(c);
}
// Sort layers by priority (ascending = higher priority first)
const sortedPriorities = Array.from(layers.keys()).sort((a, b) => a - b);
// Try each priority layer
for (const priority of sortedPriorities) {
const candidates = filterRecentlyFailedCandidates(
layers.get(priority)!,
nowMs,
RECENT_FAILURE_AVOID_SEC,
);
const selected = this.weightedRandomSelect(
candidates,
requestedByDisplayName
? (candidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel
: mappedModel,
downstreamPolicy,
);
if (selected) {
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;
}
/**
* Select next channel for failover (exclude already-tried channels).
*/
selectNextChannel(
requestedModel: string,
excludeChannelIds: number[],
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
): SelectedChannel | null {
if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null;
const match = this.findRoute(requestedModel, downstreamPolicy);
if (!match) return null;
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
const bypassSourceModelCheck = requestedByDisplayName;
const nowIso = new Date().toISOString();
const nowMs = Date.now();
const available = match.channels.filter((c) =>
(bypassSourceModelCheck || channelSupportsRequestedModel(c.channel.sourceModel, requestedModel)) &&
c.channel.enabled &&
c.account.status === 'active' &&
!isSiteDisabled(c.site.status) &&
!!this.resolveChannelTokenValue(c) &&
(!c.channel.cooldownUntil || c.channel.cooldownUntil <= nowIso) &&
!excludeChannelIds.includes(c.channel.id),
);
if (available.length === 0) return null;
const layers = new Map<number, typeof available>();
for (const c of available) {
const p = c.channel.priority ?? 0;
if (!layers.has(p)) layers.set(p, []);
layers.get(p)!.push(c);
}
const sortedPriorities = Array.from(layers.keys()).sort((a, b) => a - b);
for (const priority of sortedPriorities) {
const candidates = filterRecentlyFailedCandidates(
layers.get(priority)!,
nowMs,
RECENT_FAILURE_AVOID_SEC,
);
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;
}
explainSelection(
requestedModel: string,
excludeChannelIds: number[] = [],
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
): RouteDecisionExplanation {
const match = this.findRoute(requestedModel, downstreamPolicy);
return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy });
}
explainSelectionForRoute(
routeId: number,
requestedModel: string,
excludeChannelIds: number[] = [],
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
): RouteDecisionExplanation {
const match = this.findRouteById(routeId, downstreamPolicy);
return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy });
}
explainSelectionRouteWide(routeId: number, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): RouteDecisionExplanation {
const match = this.findRouteById(routeId, downstreamPolicy);
const fallbackRequestedModel = match?.route.modelPattern || `route:${routeId}`;
return this.explainSelectionFromMatch(match, fallbackRequestedModel, {
bypassSourceModelCheck: true,
useChannelSourceModelForCost: true,
downstreamPolicy,
});
}
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 nowIso = new Date().toISOString();
const nowMs = Date.now();
const summary: string[] = [`命中路由:${match.route.modelPattern}`];
if (requestedByDisplayName) {
summary.push(`按显示名命中:${normalizeRouteDisplayName(match.route.displayName)}`);
summary.push('显示名仅用于聚合展示,实际转发模型按选中通道来源模型决定');
}
const availableByPriority = new Map<number, RouteChannelCandidate[]>();
const candidates: RouteDecisionCandidate[] = [];
const candidateMap = new Map<number, RouteDecisionCandidate>();
for (const row of match.channels) {
const reasonParts: string[] = [];
if (!bypassSourceModelCheck && !channelSupportsRequestedModel(row.channel.sourceModel, requestedModel)) {
reasonParts.push(`来源模型不匹配=${row.channel.sourceModel || ''}`);
}
if (!row.channel.enabled) reasonParts.push('通道禁用');
if (row.account.status !== 'active') reasonParts.push(`账号状态=${row.account.status}`);
if (isSiteDisabled(row.site.status)) reasonParts.push(`站点状态=${row.site.status || 'disabled'}`);
if (excludeChannelIds.includes(row.channel.id)) reasonParts.push('当前请求已尝试');
const tokenValue = this.resolveChannelTokenValue(row);
if (!tokenValue) reasonParts.push('令牌不可用');
if (row.channel.cooldownUntil && row.channel.cooldownUntil > nowIso) reasonParts.push('冷却中');
const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs, RECENT_FAILURE_AVOID_SEC);
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,
};
}
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, RECENT_FAILURE_AVOID_SEC);
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 = `最近失败,优先避让(${RECENT_FAILURE_AVOID_SEC / 60} 分钟窗口)`;
}
}
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,
};
}
/**
* Record success for a channel.
*/
recordSuccess(channelId: number, latencyMs: number, cost: number) {
const ch = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!ch) return;
db.update(schema.routeChannels).set({
successCount: (ch.successCount ?? 0) + 1,
totalLatencyMs: (ch.totalLatencyMs ?? 0) + latencyMs,
totalCost: (ch.totalCost ?? 0) + cost,
lastUsedAt: new Date().toISOString(),
cooldownUntil: null,
lastFailAt: null,
}).where(eq(schema.routeChannels.id, channelId)).run();
}
/**
* Record failure and set cooldown.
*/
recordFailure(channelId: number) {
const ch = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!ch) return;
const failCount = (ch.failCount ?? 0) + 1;
// Exponential backoff cooldown: 30s, 60s, 120s, 240s, max 5min
const cooldownSec = Math.min(30 * Math.pow(2, failCount - 1), 300);
const cooldownUntil = new Date(Date.now() + cooldownSec * 1000).toISOString();
db.update(schema.routeChannels).set({
failCount,
lastFailAt: new Date().toISOString(),
cooldownUntil,
}).where(eq(schema.routeChannels.id, channelId)).run();
}
/**
* Get all available models (aggregated from all routes).
*/
getAvailableModels(): string[] {
const routes = db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.enabled, true)).all();
const exposed = routes
.map((route) => getExposedModelNameForRoute(route).trim())
.filter((name) => name.length > 0);
return Array.from(new Set(exposed));
}
// --- Private methods ---
private findRoute(model: string, downstreamPolicy: DownstreamRoutingPolicy): RouteMatch | null {
let routes = db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.enabled, true)).all();
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));
}
// Find matching route by model pattern or display alias.
const matchedRoute = routes.find((r) => {
return matchesRouteRequestModel(model, r);
});
if (!matchedRoute) return null;
return this.loadRouteMatch(matchedRoute);
}
private findRouteById(routeId: number, downstreamPolicy: DownstreamRoutingPolicy): RouteMatch | null {
if (downstreamPolicy.allowedRouteIds.length > 0 && !downstreamPolicy.allowedRouteIds.includes(routeId)) {
return null;
}
const route = db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.id, routeId))
.get();
if (!route || !route.enabled) return null;
return this.loadRouteMatch(route);
}
private loadRouteMatch(route: typeof schema.tokenRoutes.$inferSelect): RouteMatch {
// Get channels with account, site and token info
const channels = db
.select()
.from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
.where(eq(schema.routeChannels.routeId, route.id))
.all();
const mapped = channels.map((row) => ({
channel: row.route_channels,
account: row.accounts,
site: row.sites,
token: row.account_tokens,
}));
return { route, channels: mapped };
}
private resolveChannelTokenValue(candidate: {
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 (!candidate.token.enabled) return null;
const token = candidate.token.token?.trim();
return token ? token : null;
}
const fallback = candidate.account.apiToken?.trim();
if (fallback) return fallback;
const siteApiKey = candidate.site?.apiKey?.trim();
return siteApiKey ? siteApiKey : null;
}
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<number, number>();
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();