mirror of
https://github.com/cita-777/metapi.git
synced 2026-11-04 23:41:57 +08:00
- 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.
1257 lines
44 KiB
TypeScript
1257 lines
44 KiB
TypeScript
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();
|
||
|