997 lines
36 KiB
TypeScript
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);
|
|
});
|
|
});
|