Files
metapi/src/server/services/tokenRouter.selection.test.ts
T
Cita 532be86e93 [codex] Add route cooldown controls (#365)
* Add route cooldown controls

* Address route cooldown review comments
2026-04-01 16:41:25 +08:00

997 lines
36 KiB
TypeScript

import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../db/index.js');
type TokenRouterModule = typeof import('./tokenRouter.js');
type ConfigModule = typeof import('../config.js');
const mockedCatalogRoutingCost = vi.fn<(
input: { siteId: number; accountId: number; modelName: string }
) => number | null>(() => null);
vi.mock('./modelPricingService.js', async () => {
const actual = await vi.importActual<typeof import('./modelPricingService.js')>('./modelPricingService.js');
return {
...actual,
getCachedModelRoutingReferenceCost: mockedCatalogRoutingCost,
};
});
describe('TokenRouter selection scoring', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let TokenRouter: TokenRouterModule['TokenRouter'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
let flushSiteRuntimeHealthPersistence: TokenRouterModule['flushSiteRuntimeHealthPersistence'];
let config: ConfigModule['config'];
let dataDir = '';
let idSeed = 0;
let originalRoutingWeights: typeof config.routingWeights;
let originalRoutingFallbackUnitCost: number;
const nextId = () => {
idSeed += 1;
return idSeed;
};
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-token-router-selection-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const tokenRouterModule = await import('./tokenRouter.js');
const configModule = await import('../config.js');
db = dbModule.db;
schema = dbModule.schema;
TokenRouter = tokenRouterModule.TokenRouter;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
flushSiteRuntimeHealthPersistence = tokenRouterModule.flushSiteRuntimeHealthPersistence;
config = configModule.config;
originalRoutingWeights = { ...config.routingWeights };
originalRoutingFallbackUnitCost = config.routingFallbackUnitCost;
});
beforeEach(async () => {
idSeed = 0;
mockedCatalogRoutingCost.mockReset();
mockedCatalogRoutingCost.mockReturnValue(null);
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.settings).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
});
afterAll(() => {
config.routingWeights = { ...originalRoutingWeights };
config.routingFallbackUnitCost = originalRoutingFallbackUnitCost;
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
delete process.env.DATA_DIR;
});
async function createRoute(modelPattern: string) {
return await db.insert(schema.tokenRoutes).values({
modelPattern,
enabled: true,
}).returning().get();
}
async function createSite(namePrefix: string) {
const id = nextId();
return await db.insert(schema.sites).values({
name: `${namePrefix}-${id}`,
url: `https://${namePrefix}-${id}.example.com`,
platform: 'new-api',
status: 'active',
}).returning().get();
}
async function createAccount(siteId: number, usernamePrefix: string) {
const id = nextId();
return await db.insert(schema.accounts).values({
siteId,
username: `${usernamePrefix}-${id}`,
accessToken: `access-${id}`,
apiToken: `sk-${id}`,
status: 'active',
}).returning().get();
}
async function createToken(accountId: number, name: string) {
return await db.insert(schema.accountTokens).values({
accountId,
name,
token: `token-${name}-${nextId()}`,
enabled: true,
isDefault: false,
}).returning().get();
}
it('reuses a preferred channel only while it remains healthy', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('gpt-5.2');
const site = await createSite('sticky-site');
const account = await createAccount(site.id, 'sticky-user');
const tokenA = await createToken(account.id, 'sticky-a');
const tokenB = await createToken(account.id, 'sticky-b');
const preferredChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
failCount: 0,
}).returning().get();
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
failCount: 0,
}).run();
const router = new TokenRouter();
const selected = await router.selectPreferredChannel('gpt-5.2', preferredChannel.id);
expect(selected?.channel.id).toBe(preferredChannel.id);
await db.update(schema.routeChannels).set({
failCount: 4,
lastFailAt: new Date().toISOString(),
}).where(eq(schema.routeChannels.id, preferredChannel.id)).run();
invalidateTokenRouterCache();
await expect(router.selectPreferredChannel('gpt-5.2', preferredChannel.id)).resolves.toBeNull();
});
it('normalizes probability across channels on the same site', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('claude-haiku-4-5-20251001');
const siteA = await createSite('site-a');
const accountA = await createAccount(siteA.id, 'user-a');
const tokenA1 = await createToken(accountA.id, 'a-1');
const tokenA2 = await createToken(accountA.id, 'a-2');
const siteB = await createSite('site-b');
const accountB = await createAccount(siteB.id, 'user-b');
const tokenB = await createToken(accountB.id, 'b-1');
const channelA1 = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA1.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const channelA2 = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA2.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const decision = await new TokenRouter().explainSelection('claude-haiku-4-5-20251001');
const probMap = new Map(decision.candidates.map((candidate) => [candidate.channelId, candidate.probability]));
const probA1 = probMap.get(channelA1.id) ?? 0;
const probA2 = probMap.get(channelA2.id) ?? 0;
const probB = probMap.get(channelB.id) ?? 0;
expect(probA1).toBeCloseTo(25, 1);
expect(probA2).toBeCloseTo(25, 1);
expect(probB).toBeCloseTo(50, 1);
expect(probA1 + probA2).toBeCloseTo(probB, 1);
});
it('uses observed channel cost from real routing results when scoring cost priority', async () => {
config.routingWeights = {
baseWeightFactor: 0.35,
valueScoreFactor: 0.65,
costWeight: 1,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('claude-opus-4-6');
const siteCheap = await createSite('cheap-site');
const accountCheap = await createAccount(siteCheap.id, 'cheap-user');
const tokenCheap = await createToken(accountCheap.id, 'cheap-token');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountCheap.id,
tokenId: tokenCheap.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 10,
failCount: 0,
totalCost: 0.01,
}).run();
const siteExpensive = await createSite('expensive-site');
const accountExpensive = await createAccount(siteExpensive.id, 'expensive-user');
const tokenExpensive = await createToken(accountExpensive.id, 'exp-token');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountExpensive.id,
tokenId: tokenExpensive.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 10,
failCount: 0,
totalCost: 0.1,
}).run();
const decision = await new TokenRouter().explainSelection('claude-opus-4-6');
const cheapCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('cheap-site'));
const expensiveCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('expensive-site'));
expect(cheapCandidate).toBeTruthy();
expect(expensiveCandidate).toBeTruthy();
expect((cheapCandidate?.probability || 0)).toBeGreaterThan(expensiveCandidate?.probability || 0);
expect(cheapCandidate?.reason || '').toContain('成本=实测');
expect(expensiveCandidate?.reason || '').toContain('成本=实测');
});
it('uses runtime-configured fallback unit cost when observed and configured costs are missing', async () => {
config.routingWeights = {
baseWeightFactor: 0.35,
valueScoreFactor: 0.65,
costWeight: 1,
balanceWeight: 0,
usageWeight: 0,
};
config.routingFallbackUnitCost = 0.02;
const route = await createRoute('claude-sonnet-4-6');
const siteFallback = await createSite('fallback-site');
const accountFallback = await createAccount(siteFallback.id, 'fallback-user');
const tokenFallback = await createToken(accountFallback.id, 'fallback-token');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountFallback.id,
tokenId: tokenFallback.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 0,
failCount: 0,
totalCost: 0,
}).run();
const siteObserved = await createSite('observed-site');
const accountObserved = await createAccount(siteObserved.id, 'observed-user');
const tokenObserved = await createToken(accountObserved.id, 'observed-token');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountObserved.id,
tokenId: tokenObserved.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 10,
failCount: 0,
totalCost: 2, // unit cost 0.2
}).run();
const decision = await new TokenRouter().explainSelection('claude-sonnet-4-6');
const fallbackCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('fallback-site'));
const observedCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('observed-site'));
expect(fallbackCandidate).toBeTruthy();
expect(observedCandidate).toBeTruthy();
expect((fallbackCandidate?.probability || 0)).toBeGreaterThan(observedCandidate?.probability || 0);
expect(fallbackCandidate?.reason || '').toContain('成本=默认:0.020000');
});
it('penalizes fallback-cost channels when fallback unit cost is set very high', async () => {
config.routingWeights = {
baseWeightFactor: 0.35,
valueScoreFactor: 0.65,
costWeight: 0.75,
balanceWeight: 0.15,
usageWeight: 0.1,
};
config.routingFallbackUnitCost = 1000;
const route = await createRoute('gpt-5-nano');
const siteFallback = await createSite('fallback-high-balance');
const accountFallback = await db.insert(schema.accounts).values({
siteId: siteFallback.id,
username: `fallback-high-balance-${nextId()}`,
accessToken: `access-${nextId()}`,
apiToken: `sk-${nextId()}`,
status: 'active',
balance: 10_000,
}).returning().get();
const tokenFallback = await createToken(accountFallback.id, 'fallback-token');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountFallback.id,
tokenId: tokenFallback.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 0,
failCount: 0,
totalCost: 0,
}).run();
const siteObserved = await createSite('observed-low-balance');
const accountObserved = await db.insert(schema.accounts).values({
siteId: siteObserved.id,
username: `observed-low-balance-${nextId()}`,
accessToken: `access-${nextId()}`,
apiToken: `sk-${nextId()}`,
status: 'active',
balance: 0,
}).returning().get();
const tokenObserved = await createToken(accountObserved.id, 'observed-token');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountObserved.id,
tokenId: tokenObserved.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 10,
failCount: 0,
totalCost: 10, // observed unit cost = 1
}).run();
const decision = await new TokenRouter().explainSelection('gpt-5-nano');
const fallbackCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('fallback-high-balance'));
const observedCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('observed-low-balance'));
expect(fallbackCandidate).toBeTruthy();
expect(observedCandidate).toBeTruthy();
expect((fallbackCandidate?.probability || 0)).toBeLessThan(1);
expect((observedCandidate?.probability || 0)).toBeGreaterThan(99);
expect(fallbackCandidate?.reason || '').toContain('成本=默认:1000.000000');
});
it('uses cached catalog routing cost when observed and configured costs are missing', async () => {
config.routingWeights = {
baseWeightFactor: 0.35,
valueScoreFactor: 0.65,
costWeight: 1,
balanceWeight: 0,
usageWeight: 0,
};
config.routingFallbackUnitCost = 100;
const route = await createRoute('claude-sonnet-4-5-20250929');
const siteCatalog = await createSite('catalog-site');
const accountCatalog = await createAccount(siteCatalog.id, 'catalog-user');
const tokenCatalog = await createToken(accountCatalog.id, 'catalog-token');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountCatalog.id,
tokenId: tokenCatalog.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 0,
failCount: 0,
totalCost: 0,
}).run();
const siteFallback = await createSite('fallback-site');
const accountFallback = await createAccount(siteFallback.id, 'fallback-user');
const tokenFallback = await createToken(accountFallback.id, 'fallback-token');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountFallback.id,
tokenId: tokenFallback.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 0,
failCount: 0,
totalCost: 0,
}).run();
mockedCatalogRoutingCost.mockImplementation(({ accountId, modelName }) => {
if (accountId !== accountCatalog.id) return null;
if (modelName !== 'claude-sonnet-4-5-20250929') return null;
return 0.2;
});
const decision = await new TokenRouter().explainSelection('claude-sonnet-4-5-20250929');
const catalogCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('catalog-site'));
const fallbackCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('fallback-site'));
expect(catalogCandidate).toBeTruthy();
expect(fallbackCandidate).toBeTruthy();
expect((catalogCandidate?.probability || 0)).toBeGreaterThan(fallbackCandidate?.probability || 0);
expect(catalogCandidate?.reason || '').toContain('成本=目录:0.200000');
expect(fallbackCandidate?.reason || '').toContain('成本=默认:100.000000');
});
it('downweights a site after transient failures and restores it quickly after success', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('gpt-5.4');
const siteA = await createSite('runtime-a');
const accountA = await createAccount(siteA.id, 'runtime-user-a');
const tokenA = await createToken(accountA.id, 'runtime-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('runtime-b');
const accountB = await createAccount(siteB.id, 'runtime-user-b');
const tokenB = await createToken(accountB.id, 'runtime-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
let decision = await router.explainSelection('gpt-5.4');
let candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
let candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(candidateA?.probability).toBeCloseTo(50, 1);
expect(candidateB?.probability).toBeCloseTo(50, 1);
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Bad gateway',
});
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
invalidateTokenRouterCache();
decision = await router.explainSelection('gpt-5.4');
candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(candidateA).toBeTruthy();
expect(candidateB).toBeTruthy();
expect((candidateA?.probability || 0)).toBeLessThan(30);
expect(candidateA?.reason || '').toContain('运行时健康=');
expect((candidateB?.probability || 0)).toBeGreaterThan(70);
await router.recordSuccess(channelA.id, 800, 0);
invalidateTokenRouterCache();
decision = await router.explainSelection('gpt-5.4');
candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect((candidateA?.probability || 0)).toBeGreaterThan(40);
expect((candidateB?.probability || 0)).toBeLessThan(60);
});
it('opens a site breaker after repeated transient failures and closes it after recovery', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('gpt-5.3');
const siteA = await createSite('breaker-a');
const accountA = await createAccount(siteA.id, 'breaker-user-a');
const tokenA = await createToken(accountA.id, 'breaker-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('breaker-b');
const accountB = await createAccount(siteB.id, 'breaker-user-b');
const tokenB = await createToken(accountB.id, 'breaker-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
for (let index = 0; index < 3; index += 1) {
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Gateway timeout',
});
}
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
invalidateTokenRouterCache();
let decision = await router.explainSelection('gpt-5.3');
const breakerCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
const breakerCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(breakerCandidateA?.reason || '').toContain('站点熔断');
expect((breakerCandidateA?.probability || 0)).toBe(0);
expect((breakerCandidateB?.probability || 0)).toBe(100);
expect(decision.summary.join(' ')).toContain('站点熔断避让');
await router.recordSuccess(channelA.id, 600, 0);
invalidateTokenRouterCache();
decision = await router.explainSelection('gpt-5.3');
const recoveredCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
const recoveredCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect((recoveredCandidateA?.probability || 0)).toBeGreaterThan(30);
expect((recoveredCandidateB?.probability || 0)).toBeLessThan(70);
});
it('clears persisted runtime breaker state when channel cooldown is manually cleared', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('gpt-5.4');
const siteA = await createSite('clear-breaker-a');
const accountA = await createAccount(siteA.id, 'clear-breaker-user-a');
const tokenA = await createToken(accountA.id, 'clear-breaker-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('clear-breaker-b');
const accountB = await createAccount(siteB.id, 'clear-breaker-user-b');
const tokenB = await createToken(accountB.id, 'clear-breaker-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
for (let index = 0; index < 3; index += 1) {
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Bad gateway',
modelName: 'gpt-5.4',
});
}
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
consecutiveFailCount: 0,
cooldownLevel: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
invalidateTokenRouterCache();
let decision = await router.explainSelection('gpt-5.4');
const breakerCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
const breakerCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(breakerCandidateA?.reason || '').toContain('熔断');
expect((breakerCandidateA?.probability || 0)).toBe(0);
expect((breakerCandidateB?.probability || 0)).toBe(100);
await router.clearChannelFailureState([channelA.id]);
resetSiteRuntimeHealthState();
invalidateTokenRouterCache();
const refreshedChannel = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.id, channelA.id))
.get();
expect(refreshedChannel).toMatchObject({
failCount: 0,
lastFailAt: null,
consecutiveFailCount: 0,
cooldownLevel: 0,
cooldownUntil: null,
});
decision = await router.explainSelection('gpt-5.4');
const recoveredCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
const recoveredCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(recoveredCandidateA?.reason || '').not.toContain('熔断');
expect((recoveredCandidateA?.probability || 0)).toBeGreaterThan(30);
expect((recoveredCandidateB?.probability || 0)).toBeLessThan(70);
});
it('does not open a site breaker for repeated timeout validation errors', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('gpt-5.4');
const siteA = await createSite('timeout-validation-a');
const accountA = await createAccount(siteA.id, 'timeout-validation-user-a');
const tokenA = await createToken(accountA.id, 'timeout-validation-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('timeout-validation-b');
const accountB = await createAccount(siteB.id, 'timeout-validation-user-b');
const tokenB = await createToken(accountB.id, 'timeout-validation-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
for (let index = 0; index < 3; index += 1) {
await router.recordFailure(channelA.id, {
status: 400,
errorText: 'invalid timeout parameter',
});
}
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
invalidateTokenRouterCache();
const decision = await router.explainSelection('gpt-5.4');
const candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
const candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(candidateA).toBeTruthy();
expect(candidateB).toBeTruthy();
expect(candidateA?.reason || '').not.toContain('站点熔断');
expect(candidateB?.reason || '').not.toContain('站点熔断');
expect(decision.summary.join(' ')).not.toContain('站点熔断避让');
expect((candidateA?.probability || 0)).toBeGreaterThan(0);
});
it('uses persisted site success and latency history to prefer historically healthier sites', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('claude-4-sonnet');
const siteStable = await createSite('history-stable');
const accountStable = await createAccount(siteStable.id, 'history-user-stable');
const tokenStable = await createToken(accountStable.id, 'history-token-stable');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountStable.id,
tokenId: tokenStable.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 90,
failCount: 10,
totalLatencyMs: 90 * 240,
}).run();
const siteWeak = await createSite('history-weak');
const accountWeak = await createAccount(siteWeak.id, 'history-user-weak');
const tokenWeak = await createToken(accountWeak.id, 'history-token-weak');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountWeak.id,
tokenId: tokenWeak.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 20,
failCount: 30,
totalLatencyMs: 20 * 5200,
}).run();
const decision = await new TokenRouter().explainSelection('claude-4-sonnet');
const stableCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('history-stable'));
const weakCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('history-weak'));
expect(stableCandidate).toBeTruthy();
expect(weakCandidate).toBeTruthy();
expect((stableCandidate?.probability || 0)).toBeGreaterThan(weakCandidate?.probability || 0);
expect(stableCandidate?.reason || '').toContain('历史健康=');
expect(stableCandidate?.reason || '').toContain('成功率=90.0%');
expect(weakCandidate?.reason || '').toContain('成功率=40.0%');
});
it('reloads persisted runtime health after in-memory reset', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('gpt-4o-mini');
const siteA = await createSite('persist-a');
const accountA = await createAccount(siteA.id, 'persist-user-a');
const tokenA = await createToken(accountA.id, 'persist-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('persist-b');
const accountB = await createAccount(siteB.id, 'persist-user-b');
const tokenB = await createToken(accountB.id, 'persist-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Gateway timeout',
modelName: 'gpt-4o-mini',
});
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
await flushSiteRuntimeHealthPersistence();
const persisted = await db.select().from(schema.settings)
.where(eq(schema.settings.key, 'token_router_site_runtime_health_v1'))
.get();
expect(persisted?.value).toBeTruthy();
resetSiteRuntimeHealthState();
invalidateTokenRouterCache();
const decision = await new TokenRouter().explainSelection('gpt-4o-mini');
const candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
const candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(candidateA).toBeTruthy();
expect(candidateB).toBeTruthy();
expect((candidateA?.probability || 0)).toBeLessThan((candidateB?.probability || 0));
expect(candidateA?.reason || '').toContain('运行时健康=');
});
it('penalizes the failed model more than unrelated models on the same site', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const gptRoute = await createRoute('gpt-5.4');
const claudeRoute = await createRoute('claude-sonnet-4-6');
const siteA = await createSite('model-aware-a');
const accountA = await createAccount(siteA.id, 'model-aware-user-a');
const tokenA = await createToken(accountA.id, 'model-aware-token-a');
const gptChannelA = await db.insert(schema.routeChannels).values({
routeId: gptRoute.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values({
routeId: claudeRoute.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).run();
const siteB = await createSite('model-aware-b');
const accountB = await createAccount(siteB.id, 'model-aware-user-b');
const tokenB = await createToken(accountB.id, 'model-aware-token-b');
await db.insert(schema.routeChannels).values([
{
routeId: gptRoute.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
},
{
routeId: claudeRoute.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
},
]).run();
const router = new TokenRouter();
await router.recordFailure(gptChannelA.id, {
status: 502,
errorText: 'Bad gateway',
modelName: 'gpt-5.4',
});
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, gptChannelA.id)).run();
invalidateTokenRouterCache();
const gptDecision = await router.explainSelection('gpt-5.4');
const claudeDecision = await router.explainSelection('claude-sonnet-4-6');
const gptCandidateA = gptDecision.candidates.find((candidate) => candidate.siteName.startsWith('model-aware-a'));
const claudeCandidateA = claudeDecision.candidates.find((candidate) => candidate.siteName.startsWith('model-aware-a'));
expect(gptCandidateA).toBeTruthy();
expect(claudeCandidateA).toBeTruthy();
expect((gptCandidateA?.probability || 0)).toBeLessThan((claudeCandidateA?.probability || 0));
expect(gptCandidateA?.reason || '').toContain('模型=');
});
it('stable_first deterministically chooses the healthiest candidate', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.1',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const siteA = await createSite('stable-first-a');
const accountA = await createAccount(siteA.id, 'stable-first-user-a');
const tokenA = await createToken(accountA.id, 'stable-first-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('stable-first-b');
const accountB = await createAccount(siteB.id, 'stable-first-user-b');
const tokenB = await createToken(accountB.id, 'stable-first-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Gateway timeout',
modelName: 'gpt-5.1',
});
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
invalidateTokenRouterCache();
const preview = await router.previewSelectedChannel('gpt-5.1');
const decision = await router.explainSelection('gpt-5.1');
expect(preview?.channel.id).toBe(channelB.id);
expect(decision.summary.join(' ')).toContain('稳定优先');
expect(decision.selectedChannelId).toBe(channelB.id);
});
});