feat: add round robin routing and improve token binding UX
This commit is contained in:
@@ -374,7 +374,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
|
||||
for (const row of snapshot.accounts.tokenRoutes) {
|
||||
statements.push({
|
||||
table: 'token_routes',
|
||||
columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'decision_snapshot', 'decision_refreshed_at', 'enabled', 'created_at', 'updated_at'],
|
||||
columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy', 'enabled', 'created_at', 'updated_at'],
|
||||
values: [
|
||||
asNumber(row.id, 0),
|
||||
asNullableString(row.modelPattern),
|
||||
@@ -383,6 +383,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
|
||||
asNullableString(row.modelMapping),
|
||||
asNullableString(row.decisionSnapshot),
|
||||
asNullableString(row.decisionRefreshedAt),
|
||||
asNullableString(row.routingStrategy),
|
||||
asBoolean(row.enabled, true),
|
||||
asNullableString(row.createdAt),
|
||||
asNullableString(row.updatedAt),
|
||||
@@ -393,7 +394,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
|
||||
for (const row of snapshot.accounts.routeChannels) {
|
||||
statements.push({
|
||||
table: 'route_channels',
|
||||
columns: ['id', 'route_id', 'account_id', 'token_id', 'source_model', 'priority', 'weight', 'enabled', 'manual_override', 'success_count', 'fail_count', 'total_latency_ms', 'total_cost', 'last_used_at', 'last_fail_at', 'cooldown_until'],
|
||||
columns: ['id', 'route_id', 'account_id', 'token_id', 'source_model', 'priority', 'weight', 'enabled', 'manual_override', 'success_count', 'fail_count', 'total_latency_ms', 'total_cost', 'last_used_at', 'last_selected_at', 'last_fail_at', 'consecutive_fail_count', 'cooldown_level', 'cooldown_until'],
|
||||
values: [
|
||||
asNumber(row.id, 0),
|
||||
asNumber(row.routeId, 0),
|
||||
@@ -409,7 +410,10 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
|
||||
asNumber(row.totalLatencyMs, 0),
|
||||
asNumber(row.totalCost, 0),
|
||||
asNullableString(row.lastUsedAt),
|
||||
asNullableString(row.lastSelectedAt),
|
||||
asNullableString(row.lastFailAt),
|
||||
asNumber(row.consecutiveFailCount, 0),
|
||||
asNumber(row.cooldownLevel, 0),
|
||||
asNullableString(row.cooldownUntil),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
|
||||
|
||||
export const DEFAULT_ROUTE_ROUTING_STRATEGY: RouteRoutingStrategy = 'weighted';
|
||||
|
||||
export function normalizeRouteRoutingStrategy(value: unknown): RouteRoutingStrategy {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'round_robin') return 'round_robin';
|
||||
return DEFAULT_ROUTE_ROUTING_STRATEGY;
|
||||
}
|
||||
|
||||
export function isRoundRobinRouteRoutingStrategy(value: unknown): boolean {
|
||||
return normalizeRouteRoutingStrategy(value) === 'round_robin';
|
||||
}
|
||||
@@ -127,6 +127,7 @@ describe('TokenRouter runtime cache', () => {
|
||||
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
routingStrategy: 'weighted',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
@@ -168,4 +169,154 @@ describe('TokenRouter runtime cache', () => {
|
||||
expect(thirdCooldownMs).toBeGreaterThanOrEqual(25_000);
|
||||
expect(thirdCooldownMs).toBeLessThanOrEqual(35_000);
|
||||
});
|
||||
|
||||
it('round robins across all available channels regardless of priority', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'round-robin-site',
|
||||
url: 'https://round-robin-site.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'round-robin-user',
|
||||
accessToken: 'round-robin-access-token',
|
||||
apiToken: 'round-robin-api-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const token = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'round-robin-token',
|
||||
token: 'sk-round-robin-token',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
}).returning().get();
|
||||
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
routingStrategy: 'round_robin',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const channels = await db.insert(schema.routeChannels).values([
|
||||
{ routeId: route.id, accountId: account.id, tokenId: token.id, priority: 0, weight: 10, enabled: true },
|
||||
{ routeId: route.id, accountId: account.id, tokenId: token.id, priority: 3, weight: 10, enabled: true },
|
||||
{ routeId: route.id, accountId: account.id, tokenId: token.id, priority: 9, weight: 10, enabled: true },
|
||||
]).returning().all();
|
||||
|
||||
const router = new TokenRouter();
|
||||
|
||||
const first = await router.selectChannel('gpt-4o-mini');
|
||||
const second = await router.selectChannel('gpt-4o-mini');
|
||||
const third = await router.selectChannel('gpt-4o-mini');
|
||||
const fourth = await router.selectChannel('gpt-4o-mini');
|
||||
|
||||
expect(first?.channel.id).toBe(channels[0].id);
|
||||
expect(second?.channel.id).toBe(channels[1].id);
|
||||
expect(third?.channel.id).toBe(channels[2].id);
|
||||
expect(fourth?.channel.id).toBe(channels[0].id);
|
||||
});
|
||||
|
||||
it('applies staged cooldowns for round robin after every three consecutive failures', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'round-robin-cooldown-site',
|
||||
url: 'https://round-robin-cooldown-site.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'round-robin-cooldown-user',
|
||||
accessToken: 'round-robin-cooldown-access-token',
|
||||
apiToken: 'round-robin-cooldown-api-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const token = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'round-robin-cooldown-token',
|
||||
token: 'sk-round-robin-cooldown-token',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
}).returning().get();
|
||||
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
routingStrategy: 'round_robin',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const channel = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: account.id,
|
||||
tokenId: token.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const router = new TokenRouter();
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await router.recordFailure(channel.id);
|
||||
}
|
||||
let current = await db.select().from(schema.routeChannels)
|
||||
.where(eq(schema.routeChannels.id, channel.id))
|
||||
.get();
|
||||
expect(current?.cooldownUntil).toBeNull();
|
||||
expect(current?.consecutiveFailCount).toBe(2);
|
||||
expect(current?.cooldownLevel).toBe(0);
|
||||
|
||||
let startedAt = Date.now();
|
||||
await router.recordFailure(channel.id);
|
||||
current = await db.select().from(schema.routeChannels)
|
||||
.where(eq(schema.routeChannels.id, channel.id))
|
||||
.get();
|
||||
let cooldownMs = Date.parse(String(current?.cooldownUntil || '')) - startedAt;
|
||||
expect(current?.consecutiveFailCount).toBe(0);
|
||||
expect(current?.cooldownLevel).toBe(1);
|
||||
expect(cooldownMs).toBeGreaterThanOrEqual(9 * 60 * 1000);
|
||||
expect(cooldownMs).toBeLessThanOrEqual(11 * 60 * 1000);
|
||||
|
||||
await db.update(schema.routeChannels).set({ cooldownUntil: null }).where(eq(schema.routeChannels.id, channel.id)).run();
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await router.recordFailure(channel.id);
|
||||
}
|
||||
startedAt = Date.now();
|
||||
await router.recordFailure(channel.id);
|
||||
current = await db.select().from(schema.routeChannels)
|
||||
.where(eq(schema.routeChannels.id, channel.id))
|
||||
.get();
|
||||
cooldownMs = Date.parse(String(current?.cooldownUntil || '')) - startedAt;
|
||||
expect(current?.cooldownLevel).toBe(2);
|
||||
expect(cooldownMs).toBeGreaterThanOrEqual(59 * 60 * 1000);
|
||||
expect(cooldownMs).toBeLessThanOrEqual(61 * 60 * 1000);
|
||||
|
||||
await db.update(schema.routeChannels).set({ cooldownUntil: null }).where(eq(schema.routeChannels.id, channel.id)).run();
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await router.recordFailure(channel.id);
|
||||
}
|
||||
startedAt = Date.now();
|
||||
await router.recordFailure(channel.id);
|
||||
current = await db.select().from(schema.routeChannels)
|
||||
.where(eq(schema.routeChannels.id, channel.id))
|
||||
.get();
|
||||
cooldownMs = Date.parse(String(current?.cooldownUntil || '')) - startedAt;
|
||||
expect(current?.cooldownLevel).toBe(3);
|
||||
expect(cooldownMs).toBeGreaterThanOrEqual(23 * 60 * 60 * 1000);
|
||||
expect(cooldownMs).toBeLessThanOrEqual(25 * 60 * 60 * 1000);
|
||||
|
||||
await router.recordSuccess(channel.id, 320, 0.12);
|
||||
current = await db.select().from(schema.routeChannels)
|
||||
.where(eq(schema.routeChannels.id, channel.id))
|
||||
.get();
|
||||
expect(current?.consecutiveFailCount).toBe(0);
|
||||
expect(current?.cooldownLevel).toBe(0);
|
||||
expect(current?.cooldownUntil).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+391
-258
@@ -1,9 +1,14 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { config } from '../config.js';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { config } from '../config.js';
|
||||
import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js';
|
||||
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
|
||||
import {
|
||||
DEFAULT_ROUTE_ROUTING_STRATEGY,
|
||||
normalizeRouteRoutingStrategy,
|
||||
type RouteRoutingStrategy,
|
||||
} from './routeRoutingStrategy.js';
|
||||
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
|
||||
|
||||
interface RouteMatch {
|
||||
route: typeof schema.tokenRoutes.$inferSelect;
|
||||
@@ -34,6 +39,8 @@ type FailureAwareChannel = {
|
||||
|
||||
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;
|
||||
@@ -51,6 +58,11 @@ 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;
|
||||
@@ -348,14 +360,33 @@ function resolveActualModelForSelectedChannel(
|
||||
requestedModel: string,
|
||||
route: typeof schema.tokenRoutes.$inferSelect,
|
||||
mappedModel: string,
|
||||
channelSourceModel: string | null | undefined,
|
||||
channelSourceModel: string | null | undefined,
|
||||
): string {
|
||||
const sourceModel = normalizeChannelSourceModel(channelSourceModel);
|
||||
if (isRouteDisplayNameMatch(requestedModel, route.displayName) && sourceModel) {
|
||||
return sourceModel;
|
||||
}
|
||||
return mappedModel;
|
||||
}
|
||||
}
|
||||
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);
|
||||
@@ -399,149 +430,32 @@ function isExplicitTokenChannel(candidate: RouteChannelCandidate): boolean {
|
||||
}
|
||||
|
||||
export class TokenRouter {
|
||||
/**
|
||||
* Find matching route and select a channel for the given model.
|
||||
* Returns null if no route/channel available.
|
||||
*/
|
||||
/**
|
||||
* 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;
|
||||
|
||||
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
|
||||
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
|
||||
const bypassSourceModelCheck = requestedByDisplayName;
|
||||
if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null;
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const nowMs = Date.now();
|
||||
const available = match.channels.filter((candidate) => (
|
||||
this.getCandidateEligibilityReasons(candidate, {
|
||||
requestedModel,
|
||||
bypassSourceModelCheck,
|
||||
nowIso,
|
||||
}).length === 0
|
||||
));
|
||||
|
||||
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,
|
||||
);
|
||||
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;
|
||||
}
|
||||
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,
|
||||
requestedModel: string,
|
||||
excludeChannelIds: number[],
|
||||
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
|
||||
): Promise<SelectedChannel | null> {
|
||||
if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null;
|
||||
|
||||
if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null;
|
||||
|
||||
const match = await 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((candidate) => (
|
||||
this.getCandidateEligibilityReasons(candidate, {
|
||||
requestedModel,
|
||||
bypassSourceModelCheck,
|
||||
excludeChannelIds,
|
||||
nowIso,
|
||||
}).length === 0
|
||||
));
|
||||
|
||||
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,
|
||||
);
|
||||
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;
|
||||
}
|
||||
if (!match) return null;
|
||||
return await this.selectFromMatch(match, requestedModel, downstreamPolicy, excludeChannelIds);
|
||||
}
|
||||
|
||||
async explainSelection(
|
||||
requestedModel: string,
|
||||
@@ -608,7 +522,7 @@ export class TokenRouter {
|
||||
match: RouteMatch | null,
|
||||
requestedModel: string,
|
||||
options: ExplainSelectionOptions = {},
|
||||
): RouteDecisionExplanation {
|
||||
): RouteDecisionExplanation {
|
||||
const excludeChannelIds = options.excludeChannelIds ?? [];
|
||||
const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY;
|
||||
|
||||
@@ -621,20 +535,24 @@ export class TokenRouter {
|
||||
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 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>();
|
||||
|
||||
@@ -646,12 +564,14 @@ export class TokenRouter {
|
||||
nowIso,
|
||||
});
|
||||
|
||||
const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs);
|
||||
const eligible = reasonParts.length === 0;
|
||||
const candidate: RouteDecisionCandidate = {
|
||||
channelId: row.channel.id,
|
||||
accountId: row.account.id,
|
||||
username: row.account.username || `account-${row.account.id}`,
|
||||
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,
|
||||
@@ -672,64 +592,126 @@ export class TokenRouter {
|
||||
}
|
||||
}
|
||||
|
||||
if (availableByPriority.size === 0) {
|
||||
summary.push('没有可用通道(全部被禁用、站点不可用、冷却或令牌不可用)');
|
||||
return {
|
||||
requestedModel,
|
||||
actualModel: mappedModel,
|
||||
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;
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
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('本次未选出通道');
|
||||
@@ -815,54 +797,87 @@ export class TokenRouter {
|
||||
/**
|
||||
* 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;
|
||||
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,
|
||||
}).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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record failure and set cooldown.
|
||||
*/
|
||||
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 ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
|
||||
if (!ch) return;
|
||||
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 cooldownSec = resolveFailureBackoffSec(failCount);
|
||||
const cooldownUntil = new Date(Date.now() + cooldownSec * 1000).toISOString();
|
||||
const nowIso = new Date().toISOString();
|
||||
await db.update(schema.routeChannels).set({
|
||||
failCount,
|
||||
lastFailAt: nowIso,
|
||||
cooldownUntil,
|
||||
}).where(eq(schema.routeChannels.id, channelId)).run();
|
||||
|
||||
patchCachedChannel(channelId, (channel) => {
|
||||
channel.failCount = failCount;
|
||||
channel.lastFailAt = nowIso;
|
||||
channel.cooldownUntil = cooldownUntil;
|
||||
});
|
||||
}
|
||||
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).
|
||||
@@ -872,11 +887,99 @@ export class TokenRouter {
|
||||
const exposed = routes
|
||||
.map((route) => getExposedModelNameForRoute(route).trim())
|
||||
.filter((name) => name.length > 0);
|
||||
return Array.from(new Set(exposed));
|
||||
}
|
||||
|
||||
// --- Private methods ---
|
||||
|
||||
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();
|
||||
|
||||
@@ -973,10 +1076,40 @@ export class TokenRouter {
|
||||
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,
|
||||
modelName: string | ((candidate: RouteChannelCandidate) => string),
|
||||
downstreamPolicy: DownstreamRoutingPolicy,
|
||||
) {
|
||||
return this.calculateWeightedSelection(candidates, modelName, downstreamPolicy).selected;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user