Files
metapi/src/server/services/tokenRouter.ts
T
ciat-777 575a15232b feat: implement Codex OAuth flow with loopback callback server and management UI
- Add localCallbackServer for handling OAuth callback requests.
- Implement service functions for managing OAuth sessions and accounts.
- Create CodexAdapter for platform-specific interactions.
- Develop OAuthManagement page for managing OAuth connections and providers.
- Add tests for localCallbackServer and OAuthManagement components.
2026-03-17 11:07:25 +08:00

1257 lines
44 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, 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 { isCodexPlatform } from './oauth/codexAccount.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 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 RouteRow = typeof schema.tokenRoutes.$inferSelect;
type ChannelRow = typeof schema.routeChannels.$inferSelect;
type RouteCacheSnapshot = {
loadedAt: number;
routes: RouteRow[];
};
type RouteMatchCacheSnapshot = {
loadedAt: number;
match: RouteMatch;
};
let routeCacheSnapshot: RouteCacheSnapshot = {
loadedAt: 0,
routes: [],
};
const routeMatchCache = new Map<number, RouteMatchCacheSnapshot>();
function resolveTokenRouterCacheTtlMs(): number {
const raw = Math.trunc(config.tokenRouterCacheTtlMs || 0);
return Math.max(100, raw);
}
function isCacheFresh(loadedAt: number, nowMs: number): boolean {
return nowMs - loadedAt < resolveTokenRouterCacheTtlMs();
}
async function loadEnabledRoutes(nowMs = Date.now()): Promise<RouteRow[]> {
if (isCacheFresh(routeCacheSnapshot.loadedAt, nowMs)) {
return routeCacheSnapshot.routes;
}
const routes = await db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.enabled, true))
.all();
routeCacheSnapshot = {
loadedAt: nowMs,
routes,
};
return routes;
}
async function loadRouteMatch(route: RouteRow, nowMs = Date.now()): Promise<RouteMatch> {
const cached = routeMatchCache.get(route.id);
if (cached && isCacheFresh(cached.loadedAt, nowMs)) {
return cached.match;
}
const channels = 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(eq(schema.routeChannels.routeId, route.id))
.all();
const mapped = channels.map((row) => ({
channel: row.route_channels,
account: row.accounts,
site: row.sites,
token: row.account_tokens,
}));
const match = { route, channels: mapped };
routeMatchCache.set(route.id, {
loadedAt: nowMs,
match,
});
return match;
}
function patchCachedChannel(channelId: number, apply: (channel: ChannelRow) => void): void {
for (const entry of routeMatchCache.values()) {
const target = entry.match.channels.find((item) => item.channel.id === channelId);
if (!target) continue;
apply(target.channel);
break;
}
}
export function invalidateTokenRouterCache(): void {
routeCacheSnapshot = {
loadedAt: 0,
routes: [],
};
routeMatchCache.clear();
}
function isSiteDisabled(status?: string | null): boolean {
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<T extends { channel: FailureAwareChannel }>(
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<string>;
};
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 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 resolveRouteStrategy(route: typeof schema.tokenRoutes.$inferSelect): 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<SelectedChannel | null> {
if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null;
const match = await this.findRoute(requestedModel, downstreamPolicy);
if (!match) return null;
return await this.selectFromMatch(match, requestedModel, downstreamPolicy);
}
/**
* Select next channel for failover (exclude already-tried channels).
*/
async selectNextChannel(
requestedModel: string,
excludeChannelIds: number[],
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
): Promise<SelectedChannel | null> {
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<RouteDecisionExplanation> {
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<RouteDecisionExplanation> {
const match = await this.findRouteById(routeId, downstreamPolicy);
return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy });
}
async explainSelectionRouteWide(routeId: number, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): Promise<RouteDecisionExplanation> {
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<void> {
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<void> {
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<PricingReferenceRefreshOptions, 'useChannelSourceModelForCost'> = {},
): Promise<void> {
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<number, RouteChannelCandidate[]>();
const candidates: RouteDecisionCandidate[] = [];
const candidateMap = new Map<number, RouteDecisionCandidate>();
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<void> {
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<string>();
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<string[]> {
const routes = await loadEnabledRoutes();
const exposed = 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[] = [],
): Promise<SelectedChannel | null> {
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;
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<number, typeof available>();
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<RouteMatch | null> {
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) => (
isExactRouteModelPattern(route.modelPattern)
&& (route.modelPattern || '').trim() === model
))
|| routes.find((route) => isRouteDisplayNameMatch(model, route.displayName))
|| routes.find((route) => matchesModelPattern(model, route.modelPattern));
if (!matchedRoute) return null;
return await this.loadRouteMatch(matchedRoute);
}
private async findRouteById(routeId: number, downstreamPolicy: DownstreamRoutingPolicy): Promise<RouteMatch | null> {
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: typeof schema.tokenRoutes.$inferSelect): Promise<RouteMatch> {
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 ((candidate.site?.platform || '').trim().toLowerCase() === 'codex' || isCodexPlatform(candidate.account)) {
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<void> {
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<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();