feat(server): add proactive channel probes and load-aware stable routing / 服务端新增主动探活与负载感知稳定路由 (#330)

* wip: preserve local changes before upstream merge

* Improve proxy routing stability and add stable-first strategy

* Sync explicit-group routing strategy to eligible source routes

* Fix proxy retry and runtime health review issues

* fix: make proxy channel retry attempts configurable

* fix: harden proxy retry recovery and bookkeeping

* fix: address pr 257 review comments

* feat(proxy): add sticky session channel coordination

* feat(server): add proactive channel probes and load-aware stable routing

* test(server): stabilize session load routing selection assertion

* fix(proxy): harden codex forwarding stability

* fix(router): rotate stable-first channels by healthy order

* fix(server): address probe review feedback

* fix(server): address probe review follow-ups

* fix(ci): address CodeQL and core test failures

* feat(server): refine stable-first routing recovery and observation

* Finish PR 330 settings and review fixes

* Fix chat site endpoint CI mock

* Address latest PR 330 review comments

---------

Co-authored-by: Cita <juricek.chen@gmail.com>
This commit is contained in:
Bainily
2026-04-02 09:49:04 +08:00
committed by GitHub
parent 1133846998
commit 596d1b93cb
75 changed files with 6365 additions and 507 deletions
@@ -301,6 +301,21 @@ export function getRunningTaskByDedupeKey(key: string): BackgroundTask | null {
return task;
}
export async function waitForBackgroundTaskCompletion(taskId: string, pollIntervalMs = 25): Promise<BackgroundTask | null> {
const safePollIntervalMs = Math.max(5, Math.trunc(pollIntervalMs || 0));
while (true) {
const task = getBackgroundTask(taskId);
if (!task) return null;
if (task.status !== 'pending' && task.status !== 'running') {
return task;
}
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, safePollIntervalMs);
timer.unref?.();
});
}
}
export function summarizeCheckinResults(results: Array<{ result?: any }>): { total: number; success: number; skipped: number; failed: number } {
const summary = { total: results.length, success: 0, skipped: 0, failed: 0 };
for (const item of results) {
@@ -0,0 +1,348 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
const probeRuntimeModelMock = vi.fn();
vi.mock('./runtimeModelProbe.js', () => ({
probeRuntimeModel: (...args: unknown[]) => probeRuntimeModelMock(...args),
}));
type DbModule = typeof import('../db/index.js');
type RecoveryModule = typeof import('./channelRecoveryProbeService.js');
type CoordinatorModule = typeof import('./proxyChannelCoordinator.js');
type ConfigModule = typeof import('../config.js');
type TokenRouterModule = typeof import('./tokenRouter.js');
describe('channelRecoveryProbeService', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let runChannelRecoveryProbeSweep: RecoveryModule['runChannelRecoveryProbeSweep'];
let resetChannelRecoveryProbeState: RecoveryModule['resetChannelRecoveryProbeState'];
let proxyChannelCoordinator: CoordinatorModule['proxyChannelCoordinator'];
let resetProxyChannelCoordinatorState: CoordinatorModule['resetProxyChannelCoordinatorState'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
let config: ConfigModule['config'];
let dataDir = '';
let originalDataDir: string | undefined;
let originalConcurrencyLimit = 0;
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-channel-recovery-probe-'));
originalDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const recoveryModule = await import('./channelRecoveryProbeService.js');
const coordinatorModule = await import('./proxyChannelCoordinator.js');
const configModule = await import('../config.js');
const tokenRouterModule = await import('./tokenRouter.js');
db = dbModule.db;
schema = dbModule.schema;
runChannelRecoveryProbeSweep = recoveryModule.runChannelRecoveryProbeSweep;
resetChannelRecoveryProbeState = recoveryModule.resetChannelRecoveryProbeState;
proxyChannelCoordinator = coordinatorModule.proxyChannelCoordinator;
resetProxyChannelCoordinatorState = coordinatorModule.resetProxyChannelCoordinatorState;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
config = configModule.config;
originalConcurrencyLimit = config.proxySessionChannelConcurrencyLimit;
});
beforeEach(async () => {
probeRuntimeModelMock.mockReset();
probeRuntimeModelMock.mockResolvedValue({
status: 'supported',
latencyMs: 320,
reason: 'probe succeeded',
});
config.proxySessionChannelConcurrencyLimit = 1;
resetChannelRecoveryProbeState();
resetProxyChannelCoordinatorState();
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
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();
});
afterAll(() => {
config.proxySessionChannelConcurrencyLimit = originalConcurrencyLimit;
resetChannelRecoveryProbeState();
resetProxyChannelCoordinatorState();
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
rmSync(dataDir, { recursive: true, force: true });
if (originalDataDir === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = originalDataDir;
}
});
it('clears cooldown markers when a background probe succeeds', async () => {
const site = await db.insert(schema.sites).values({
name: 'recovery-site',
url: 'https://recovery-site.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'recovery-user',
accessToken: 'access-recovery',
apiToken: 'sk-recovery',
status: 'active',
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'sk-recovery-token',
enabled: true,
isDefault: true,
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.4',
enabled: true,
}).returning().get();
const channel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: token.id,
enabled: true,
cooldownUntil: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
lastFailAt: new Date().toISOString(),
consecutiveFailCount: 2,
cooldownLevel: 1,
}).returning().get();
await runChannelRecoveryProbeSweep();
expect(probeRuntimeModelMock).toHaveBeenCalledTimes(1);
expect(probeRuntimeModelMock.mock.calls[0]?.[0]).toMatchObject({
modelName: 'gpt-5.4',
tokenValue: 'sk-recovery-token',
});
const refreshed = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.id, channel.id))
.get();
expect(refreshed?.cooldownUntil).toBeNull();
expect(refreshed?.lastFailAt).toBeNull();
expect(refreshed?.consecutiveFailCount).toBe(0);
expect(refreshed?.cooldownLevel).toBe(0);
});
it('also probes active leased channels in the background', async () => {
const site = await db.insert(schema.sites).values({
name: 'active-site',
url: 'https://active-site.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'active-user',
accessToken: 'access-active',
apiToken: 'sk-active',
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'session',
}),
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'sk-active-token',
enabled: true,
isDefault: true,
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.2',
enabled: true,
}).returning().get();
const channel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: token.id,
enabled: true,
}).returning().get();
const lease = await proxyChannelCoordinator.acquireChannelLease({
channelId: channel.id,
accountExtraConfig: account.extraConfig,
});
expect(lease.status).toBe('acquired');
if (lease.status !== 'acquired') return;
await runChannelRecoveryProbeSweep();
expect(probeRuntimeModelMock).toHaveBeenCalledTimes(1);
expect(probeRuntimeModelMock.mock.calls[0]?.[0]).toMatchObject({
modelName: 'gpt-5.2',
tokenValue: 'sk-active-token',
});
lease.lease.release();
});
it('skips provider-directed quota cooldown channels during recovery sweeps', async () => {
const site = await db.insert(schema.sites).values({
name: 'quota-site',
url: 'https://quota-site.example.com',
platform: 'codex',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'quota-user',
accessToken: 'access-quota',
apiToken: 'sk-quota',
status: 'active',
}).returning().get();
const quotaToken = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'quota-token',
token: 'sk-quota-token',
enabled: true,
isDefault: false,
}).returning().get();
const retryToken = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'retry-token',
token: 'sk-retry-token',
enabled: true,
isDefault: false,
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.4',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values([
{
routeId: route.id,
accountId: account.id,
tokenId: quotaToken.id,
enabled: true,
cooldownUntil: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
lastFailAt: new Date().toISOString(),
failCount: 0,
consecutiveFailCount: 0,
cooldownLevel: 0,
},
{
routeId: route.id,
accountId: account.id,
tokenId: retryToken.id,
enabled: true,
cooldownUntil: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
lastFailAt: new Date().toISOString(),
failCount: 2,
consecutiveFailCount: 0,
cooldownLevel: 0,
},
]).run();
await runChannelRecoveryProbeSweep();
expect(probeRuntimeModelMock).toHaveBeenCalledTimes(1);
expect(probeRuntimeModelMock.mock.calls[0]?.[0]).toMatchObject({
tokenValue: 'sk-retry-token',
modelName: 'gpt-5.4',
});
});
it('prioritizes never-probed active channels before reprobing recently started ones', async () => {
const site = await db.insert(schema.sites).values({
name: 'priority-site',
url: 'https://priority-site.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const leases: Array<{ release: () => void }> = [];
try {
for (let index = 1; index <= 5; index += 1) {
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: `priority-user-${index}`,
accessToken: `access-priority-${index}`,
apiToken: `sk-priority-${index}`,
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'session',
}),
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: `token-${index}`,
token: `sk-priority-token-${index}`,
enabled: true,
isDefault: true,
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: `gpt-5.4-${index}`,
enabled: true,
}).returning().get();
const channel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: token.id,
enabled: true,
}).returning().get();
const lease = await proxyChannelCoordinator.acquireChannelLease({
channelId: channel.id,
accountExtraConfig: account.extraConfig,
});
expect(lease.status).toBe('acquired');
if (lease.status === 'acquired') {
leases.push(lease.lease);
}
}
const startedAt = Date.UTC(2026, 3, 1, 0, 0, 0);
await runChannelRecoveryProbeSweep(startedAt);
expect(probeRuntimeModelMock).toHaveBeenCalledTimes(4);
expect(probeRuntimeModelMock.mock.calls.map((call) => call[0]?.tokenValue)).not.toContain('sk-priority-token-5');
probeRuntimeModelMock.mockClear();
await runChannelRecoveryProbeSweep(startedAt + 5 * 60 * 1000);
expect(probeRuntimeModelMock).toHaveBeenCalledTimes(4);
const secondSweepTokens = probeRuntimeModelMock.mock.calls.map((call) => call[0]?.tokenValue);
expect(secondSweepTokens).toContain('sk-priority-token-5');
} finally {
for (const lease of leases) {
lease.release();
}
}
});
});
@@ -0,0 +1,299 @@
import { and, eq, gt, inArray, isNotNull } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { isUsableAccountToken } from './accountTokenService.js';
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
import { proxyChannelCoordinator } from './proxyChannelCoordinator.js';
import { probeRuntimeModel } from './runtimeModelProbe.js';
import { tokenRouter } from './tokenRouter.js';
import { isExactTokenRouteModelPattern } from '../../shared/tokenRoutePatterns.js';
type RecoveryProbeSource = 'cooldown' | 'active';
type RecoveryProbeCandidate = {
source: RecoveryProbeSource;
channelId: number;
modelName: string;
tokenValue: string;
account: typeof schema.accounts.$inferSelect;
site: typeof schema.sites.$inferSelect;
};
const CHANNEL_RECOVERY_SWEEP_INTERVAL_MS = 30_000;
const CHANNEL_RECOVERY_PROBE_TIMEOUT_MS = 12_000;
// Keep recovery probes conservative so they do not look like bulk health checks to upstream providers.
const CHANNEL_RECOVERY_PROBE_CONCURRENCY = 1;
const CHANNEL_RECOVERY_MAX_BATCH = 4;
const CHANNEL_RECOVERY_COOLDOWN_RECHECK_MS = 30_000;
const CHANNEL_RECOVERY_ACTIVE_RECHECK_MS = 5 * 60_000;
let recoveryProbeSchedulerTimer: ReturnType<typeof setInterval> | null = null;
let recoveryProbeSweepInFlight: Promise<void> | null = null;
const recoveryProbeInFlightKeys = new Set<string>();
const recoveryProbeLastStartedAtByKey = new Map<string, number>();
function shouldUnrefTimer(timer: ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>) {
if (typeof (timer as { unref?: () => void }).unref === 'function') {
(timer as { unref: () => void }).unref();
}
}
function buildRecoveryProbeKey(channelId: number, modelName: string): string {
return `${channelId}:${String(modelName || '').trim().toLowerCase()}`;
}
function resolveRecoveryProbeWindowMs(source: RecoveryProbeSource): number {
return source === 'cooldown'
? CHANNEL_RECOVERY_COOLDOWN_RECHECK_MS
: CHANNEL_RECOVERY_ACTIVE_RECHECK_MS;
}
function resolveProbeModelName(row: {
route_channels: typeof schema.routeChannels.$inferSelect;
token_routes: typeof schema.tokenRoutes.$inferSelect;
}): string {
const sourceModel = String(row.route_channels.sourceModel || '').trim();
if (sourceModel) return sourceModel;
const routeModelPattern = String(row.token_routes.modelPattern || '').trim();
return isExactTokenRouteModelPattern(routeModelPattern) ? routeModelPattern : '';
}
function resolveProbeTokenValue(row: {
route_channels: typeof schema.routeChannels.$inferSelect;
accounts: typeof schema.accounts.$inferSelect;
account_tokens: typeof schema.accountTokens.$inferSelect | null;
}): string | null {
if (typeof row.route_channels.tokenId === 'number' && row.route_channels.tokenId > 0) {
if (!row.account_tokens || !isUsableAccountToken(row.account_tokens)) return null;
const tokenValue = String(row.account_tokens.token || '').trim();
return tokenValue || null;
}
if (getOauthInfoFromAccount(row.accounts)) {
const accessToken = String(row.accounts.accessToken || '').trim();
return accessToken || null;
}
const fallbackApiToken = String(row.accounts.apiToken || '').trim();
return fallbackApiToken || null;
}
function isProviderDirectedCooldown(row: {
route_channels: typeof schema.routeChannels.$inferSelect;
}): boolean {
return !!row.route_channels.cooldownUntil
&& (row.route_channels.failCount ?? 0) <= 0
&& (row.route_channels.consecutiveFailCount ?? 0) <= 0
&& (row.route_channels.cooldownLevel ?? 0) <= 0;
}
async function mapWithConcurrency<T>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<void>,
): Promise<void> {
const safeConcurrency = Math.max(1, Math.min(items.length || 1, Math.trunc(concurrency || 1)));
let nextIndex = 0;
const runWorker = async () => {
while (true) {
const currentIndex = nextIndex;
nextIndex += 1;
if (currentIndex >= items.length) return;
await worker(items[currentIndex] as T, currentIndex);
}
};
await Promise.all(Array.from({ length: safeConcurrency }, () => runWorker()));
}
async function loadCoolingProbeCandidates(nowIso: string): Promise<RecoveryProbeCandidate[]> {
const rows = await db.select()
.from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.innerJoin(schema.tokenRoutes, eq(schema.routeChannels.routeId, schema.tokenRoutes.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
.where(and(
eq(schema.routeChannels.enabled, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
isNotNull(schema.routeChannels.cooldownUntil),
gt(schema.routeChannels.cooldownUntil, nowIso),
))
.all();
return rows.flatMap((row) => {
if (isProviderDirectedCooldown(row)) return [];
const modelName = resolveProbeModelName(row);
const tokenValue = resolveProbeTokenValue(row);
if (!modelName || !tokenValue) return [];
return [{
source: 'cooldown' as const,
channelId: row.route_channels.id,
modelName,
tokenValue,
account: row.accounts,
site: row.sites,
}];
});
}
async function loadActiveProbeCandidates(activeChannelIds: number[]): Promise<RecoveryProbeCandidate[]> {
if (activeChannelIds.length <= 0) return [];
const rows = await db.select()
.from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.innerJoin(schema.tokenRoutes, eq(schema.routeChannels.routeId, schema.tokenRoutes.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
.where(and(
eq(schema.routeChannels.enabled, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
inArray(schema.routeChannels.id, activeChannelIds),
))
.all();
return rows.flatMap((row) => {
const modelName = resolveProbeModelName(row);
const tokenValue = resolveProbeTokenValue(row);
if (!modelName || !tokenValue) return [];
return [{
source: 'active' as const,
channelId: row.route_channels.id,
modelName,
tokenValue,
account: row.accounts,
site: row.sites,
}];
});
}
function mergeRecoveryProbeCandidates(candidates: RecoveryProbeCandidate[]): RecoveryProbeCandidate[] {
const merged = new Map<number, RecoveryProbeCandidate>();
for (const candidate of candidates) {
const existing = merged.get(candidate.channelId);
if (!existing || (existing.source === 'active' && candidate.source === 'cooldown')) {
merged.set(candidate.channelId, candidate);
}
}
return Array.from(merged.values());
}
function shouldProbeCandidate(candidate: RecoveryProbeCandidate, nowMs: number): boolean {
const key = buildRecoveryProbeKey(candidate.channelId, candidate.modelName);
if (recoveryProbeInFlightKeys.has(key)) return false;
const lastStartedAt = recoveryProbeLastStartedAtByKey.get(key) ?? 0;
return (nowMs - lastStartedAt) >= resolveRecoveryProbeWindowMs(candidate.source);
}
function compareRecoveryProbeCandidatePriority(left: RecoveryProbeCandidate, right: RecoveryProbeCandidate): number {
const leftKey = buildRecoveryProbeKey(left.channelId, left.modelName);
const rightKey = buildRecoveryProbeKey(right.channelId, right.modelName);
const leftLastStartedAt = recoveryProbeLastStartedAtByKey.get(leftKey);
const rightLastStartedAt = recoveryProbeLastStartedAtByKey.get(rightKey);
if (leftLastStartedAt == null && rightLastStartedAt == null) {
return left.channelId - right.channelId;
}
if (leftLastStartedAt == null) return -1;
if (rightLastStartedAt == null) return 1;
if (leftLastStartedAt !== rightLastStartedAt) {
return leftLastStartedAt - rightLastStartedAt;
}
return left.channelId - right.channelId;
}
async function runRecoveryProbeCandidate(candidate: RecoveryProbeCandidate, nowMs: number): Promise<void> {
const key = buildRecoveryProbeKey(candidate.channelId, candidate.modelName);
recoveryProbeInFlightKeys.add(key);
recoveryProbeLastStartedAtByKey.set(key, nowMs);
try {
const result = await probeRuntimeModel({
site: candidate.site,
account: candidate.account,
modelName: candidate.modelName,
tokenValue: candidate.tokenValue,
timeoutMs: CHANNEL_RECOVERY_PROBE_TIMEOUT_MS,
});
if (result.status === 'supported') {
await tokenRouter.recordProbeSuccess(
candidate.channelId,
result.latencyMs ?? 0,
candidate.modelName,
);
}
} catch (error) {
console.warn(`[channel-recovery-probe] channel ${candidate.channelId} probe failed`, error);
} finally {
recoveryProbeInFlightKeys.delete(key);
}
}
export async function runChannelRecoveryProbeSweep(nowMs = Date.now()): Promise<void> {
if (recoveryProbeSweepInFlight) {
await recoveryProbeSweepInFlight;
return;
}
recoveryProbeSweepInFlight = (async () => {
const nowIso = new Date(nowMs).toISOString();
const activeChannelIds = proxyChannelCoordinator.getActiveChannelIds();
const [coolingCandidates, activeCandidates] = await Promise.all([
loadCoolingProbeCandidates(nowIso),
loadActiveProbeCandidates(activeChannelIds),
]);
const merged = mergeRecoveryProbeCandidates([
...coolingCandidates,
...activeCandidates,
]);
const dueCandidates = merged
.filter((candidate) => shouldProbeCandidate(candidate, nowMs))
.sort(compareRecoveryProbeCandidatePriority)
.slice(0, CHANNEL_RECOVERY_MAX_BATCH);
if (dueCandidates.length <= 0) return;
await mapWithConcurrency(
dueCandidates,
CHANNEL_RECOVERY_PROBE_CONCURRENCY,
async (candidate) => runRecoveryProbeCandidate(candidate, nowMs),
);
})().finally(() => {
recoveryProbeSweepInFlight = null;
});
await recoveryProbeSweepInFlight;
}
export function startChannelRecoveryProbeScheduler(intervalMs = CHANNEL_RECOVERY_SWEEP_INTERVAL_MS) {
stopChannelRecoveryProbeScheduler();
const safeIntervalMs = Math.max(10_000, Math.trunc(intervalMs || 0));
recoveryProbeSchedulerTimer = setInterval(() => {
void runChannelRecoveryProbeSweep().catch((error) => {
console.warn('[channel-recovery-probe] background sweep failed', error);
});
}, safeIntervalMs);
shouldUnrefTimer(recoveryProbeSchedulerTimer);
void runChannelRecoveryProbeSweep().catch((error) => {
console.warn('[channel-recovery-probe] initial sweep failed', error);
});
return {
enabled: true,
intervalMs: safeIntervalMs,
};
}
export function stopChannelRecoveryProbeScheduler() {
if (recoveryProbeSchedulerTimer) {
clearInterval(recoveryProbeSchedulerTimer);
recoveryProbeSchedulerTimer = null;
}
}
export function resetChannelRecoveryProbeState() {
stopChannelRecoveryProbeScheduler();
recoveryProbeSweepInFlight = null;
recoveryProbeInFlightKeys.clear();
recoveryProbeLastStartedAtByKey.clear();
}
@@ -0,0 +1,507 @@
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';
const resolveUpstreamEndpointCandidatesMock = vi.fn();
const buildUpstreamEndpointRequestMock = vi.fn();
const dispatchRuntimeRequestMock = vi.fn();
const resolveChannelProxyUrlMock = vi.fn();
const withSiteRecordProxyRequestInitMock = vi.fn();
const rebuildRoutesOnlyMock = vi.fn();
vi.mock('./upstreamEndpointRuntime.js', () => ({
resolveUpstreamEndpointCandidates: (...args: unknown[]) => resolveUpstreamEndpointCandidatesMock(...args),
buildUpstreamEndpointRequest: (...args: unknown[]) => buildUpstreamEndpointRequestMock(...args),
}));
vi.mock('./runtimeDispatch.js', () => ({
dispatchRuntimeRequest: (...args: unknown[]) => dispatchRuntimeRequestMock(...args),
}));
vi.mock('./siteProxy.js', () => ({
resolveChannelProxyUrl: (...args: unknown[]) => resolveChannelProxyUrlMock(...args),
withSiteRecordProxyRequestInit: (...args: unknown[]) => withSiteRecordProxyRequestInitMock(...args),
}));
vi.mock('./routeRefreshWorkflow.js', () => ({
rebuildRoutesOnly: (...args: unknown[]) => rebuildRoutesOnlyMock(...args),
}));
type DbModule = typeof import('../db/index.js');
type ProbeModule = typeof import('./modelAvailabilityProbeService.js');
describe('modelAvailabilityProbeService', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let executeModelAvailabilityProbe: ProbeModule['executeModelAvailabilityProbe'];
let resetProbeExecutionState: ProbeModule['__resetModelAvailabilityProbeExecutionStateForTests'];
let dataDir = '';
let originalDataDir: string | undefined;
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-model-probe-'));
originalDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const probeModule = await import('./modelAvailabilityProbeService.js');
db = dbModule.db;
schema = dbModule.schema;
executeModelAvailabilityProbe = probeModule.executeModelAvailabilityProbe;
resetProbeExecutionState = probeModule.__resetModelAvailabilityProbeExecutionStateForTests;
});
beforeEach(async () => {
resolveUpstreamEndpointCandidatesMock.mockReset();
buildUpstreamEndpointRequestMock.mockReset();
dispatchRuntimeRequestMock.mockReset();
resolveChannelProxyUrlMock.mockReset();
withSiteRecordProxyRequestInitMock.mockReset();
rebuildRoutesOnlyMock.mockReset();
rebuildRoutesOnlyMock.mockResolvedValue(undefined);
resetProbeExecutionState();
resolveUpstreamEndpointCandidatesMock.mockResolvedValue(['chat']);
buildUpstreamEndpointRequestMock.mockImplementation((input: { modelName?: string; endpoint?: string }) => ({
path: '/v1/chat/completions',
headers: {
'content-type': 'application/json',
},
body: {
model: input.modelName || 'unknown-model',
endpoint: input.endpoint || 'chat',
},
runtime: {
executor: 'default',
modelName: input.modelName || 'unknown-model',
stream: false,
},
}));
dispatchRuntimeRequestMock.mockImplementation(async (input: { request?: { body?: { model?: string } } }) => {
const modelName = String(input?.request?.body?.model || '');
if (modelName === 'gpt-ghost') {
return new Response(JSON.stringify({
error: {
message: 'The model `gpt-ghost` does not exist',
},
}), {
status: 404,
headers: {
'content-type': 'application/json',
},
});
}
return new Response(JSON.stringify({
id: 'resp_probe',
model: modelName,
choices: [
{
message: {
role: 'assistant',
content: 'OK',
},
},
],
}), {
status: 200,
headers: {
'content-type': 'application/json',
},
});
});
resolveChannelProxyUrlMock.mockReturnValue(null);
withSiteRecordProxyRequestInitMock.mockImplementation((_site: unknown, init: unknown) => init);
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(() => {
if (originalDataDir === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = originalDataDir;
}
});
it('marks supported rows true and unsupported rows false after probing', async () => {
const site = await db.insert(schema.sites).values({
name: 'probe-site',
url: 'https://probe.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'probe-user',
accessToken: '',
apiToken: 'sk-account-probe',
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'apikey',
}),
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'sk-token-probe',
enabled: true,
isDefault: true,
valueStatus: 'ready',
}).returning().get();
await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'gpt-real',
available: true,
checkedAt: '2026-03-20T00:00:00.000Z',
},
]).run();
await db.insert(schema.tokenModelAvailability).values([
{
tokenId: token.id,
modelName: 'gpt-ghost',
available: true,
checkedAt: '2026-03-20T00:00:00.000Z',
},
]).run();
const result = await executeModelAvailabilityProbe({
accountId: account.id,
rebuildRoutes: false,
});
expect(result.summary).toMatchObject({
totalAccounts: 1,
scanned: 2,
supported: 1,
unsupported: 1,
inconclusive: 0,
skippedModels: 0,
updatedRows: 2,
rebuiltRoutes: false,
});
const accountRows = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();
expect(accountRows).toHaveLength(1);
expect(accountRows[0]?.available).toBe(true);
const tokenRows = await db.select().from(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, token.id))
.all();
expect(tokenRows).toHaveLength(1);
expect(tokenRows[0]?.available).toBe(false);
});
it('skips probing obvious non-conversation models', async () => {
const site = await db.insert(schema.sites).values({
name: 'probe-site',
url: 'https://probe.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'probe-user',
accessToken: '',
apiToken: 'sk-account-probe',
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'apikey',
}),
}).returning().get();
await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'text-embedding-3-large',
available: true,
checkedAt: '2026-03-20T00:00:00.000Z',
},
]).run();
const result = await executeModelAvailabilityProbe({
accountId: account.id,
rebuildRoutes: false,
});
expect(result.summary).toMatchObject({
totalAccounts: 1,
scanned: 1,
supported: 0,
unsupported: 0,
inconclusive: 0,
skippedModels: 1,
updatedRows: 0,
});
expect(dispatchRuntimeRequestMock).not.toHaveBeenCalled();
});
it('does not mark a model unavailable when probe result is inconclusive', async () => {
dispatchRuntimeRequestMock.mockImplementation(async (input: { request?: { body?: { model?: string } } }) => {
const modelName = String(input?.request?.body?.model || '');
if (modelName === 'gpt-flaky') {
return new Response(JSON.stringify({
error: {
message: 'upstream temporary failure',
},
}), {
status: 502,
headers: {
'content-type': 'application/json',
},
});
}
return new Response(JSON.stringify({
id: 'resp_probe',
model: modelName,
choices: [
{
message: {
role: 'assistant',
content: 'OK',
},
},
],
}), {
status: 200,
headers: {
'content-type': 'application/json',
},
});
});
const site = await db.insert(schema.sites).values({
name: 'probe-site',
url: 'https://probe.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'probe-user',
accessToken: '',
apiToken: 'sk-account-probe',
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'apikey',
}),
}).returning().get();
const checkedAt = '2026-03-20T00:00:00.000Z';
await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'gpt-flaky',
available: true,
checkedAt,
},
]).run();
const result = await executeModelAvailabilityProbe({
accountId: account.id,
rebuildRoutes: false,
});
expect(result.summary).toMatchObject({
totalAccounts: 1,
scanned: 1,
supported: 0,
unsupported: 0,
inconclusive: 1,
skippedModels: 0,
updatedRows: 0,
});
const accountRows = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();
expect(accountRows).toHaveLength(1);
expect(accountRows[0]?.available).toBe(true);
expect(accountRows[0]?.checkedAt).toBe(checkedAt);
});
it('does not rebuild routes when probe only refreshes checkedAt and latency', async () => {
const site = await db.insert(schema.sites).values({
name: 'stable-site',
url: 'https://stable.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'stable-user',
accessToken: '',
apiToken: 'sk-stable',
status: 'active',
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-stable',
available: true,
checkedAt: '2026-03-20T00:00:00.000Z',
latencyMs: 123,
}).run();
const result = await executeModelAvailabilityProbe({
accountId: account.id,
rebuildRoutes: true,
});
expect(result.summary.updatedRows).toBe(1);
expect(result.summary.rebuiltRoutes).toBe(false);
expect(rebuildRoutesOnlyMock).not.toHaveBeenCalled();
});
it('rebuilds routes when probe flips availability state', async () => {
const site = await db.insert(schema.sites).values({
name: 'changed-site',
url: 'https://changed.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'changed-user',
accessToken: '',
apiToken: 'sk-changed',
status: 'active',
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-changed',
available: false,
checkedAt: '2026-03-20T00:00:00.000Z',
}).run();
const result = await executeModelAvailabilityProbe({
accountId: account.id,
rebuildRoutes: true,
});
expect(result.summary.updatedRows).toBe(1);
expect(result.summary.rebuiltRoutes).toBe(true);
expect(rebuildRoutesOnlyMock).toHaveBeenCalledTimes(1);
});
it('does not count inactive accounts in probe results', async () => {
const site = await db.insert(schema.sites).values({
name: 'inactive-site',
url: 'https://inactive.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'inactive-user',
accessToken: '',
apiToken: 'sk-inactive',
status: 'disabled',
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-inactive',
available: true,
checkedAt: '2026-03-20T00:00:00.000Z',
}).run();
const result = await executeModelAvailabilityProbe({
accountId: account.id,
rebuildRoutes: false,
});
expect(result.summary.totalAccounts).toBe(0);
expect(result.results).toEqual([]);
expect(dispatchRuntimeRequestMock).not.toHaveBeenCalled();
});
it('skips an account when another probe already holds the execution lease', async () => {
const site = await db.insert(schema.sites).values({
name: 'leased-site',
url: 'https://leased.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'leased-user',
accessToken: '',
apiToken: 'sk-leased',
status: 'active',
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-leased',
available: true,
checkedAt: '2026-03-20T00:00:00.000Z',
}).run();
let releaseFirstProbe: (() => void) | null = null;
const firstProbeStarted = new Promise<void>((resolve) => {
dispatchRuntimeRequestMock.mockImplementationOnce(async () => {
resolve();
await new Promise<void>((release) => {
releaseFirstProbe = release;
});
return new Response(JSON.stringify({
id: 'resp_probe',
choices: [{ message: { role: 'assistant', content: 'OK' } }],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
});
});
const firstProbe = executeModelAvailabilityProbe({
accountId: account.id,
rebuildRoutes: false,
});
await firstProbeStarted;
const overlappingProbe = await executeModelAvailabilityProbe({
accountId: account.id,
rebuildRoutes: false,
});
releaseFirstProbe?.();
await firstProbe;
expect(overlappingProbe.summary).toMatchObject({
totalAccounts: 1,
skipped: 1,
scanned: 0,
});
expect(overlappingProbe.results[0]).toMatchObject({
accountId: account.id,
siteId: site.id,
status: 'skipped',
message: 'model availability probe already running for account',
});
});
});
@@ -0,0 +1,447 @@
import { and, asc, eq } from 'drizzle-orm';
import { config } from '../config.js';
import { db, schema } from '../db/index.js';
import { startBackgroundTask } from './backgroundTaskService.js';
import { isUsableAccountToken, ACCOUNT_TOKEN_VALUE_STATUS_READY } from './accountTokenService.js';
import { probeRuntimeModel } from './runtimeModelProbe.js';
import * as routeRefreshWorkflow from './routeRefreshWorkflow.js';
type ProbeStatus = 'supported' | 'unsupported' | 'inconclusive' | 'skipped';
type ProbeAccountTarget = {
kind: 'account';
rowId: number;
modelName: string;
lastKnownAvailable: boolean;
account: typeof schema.accounts.$inferSelect;
site: typeof schema.sites.$inferSelect;
};
type ProbeTokenTarget = {
kind: 'token';
rowId: number;
tokenId: number;
modelName: string;
tokenValue: string;
lastKnownAvailable: boolean;
account: typeof schema.accounts.$inferSelect;
site: typeof schema.sites.$inferSelect;
};
type ProbeTarget = ProbeAccountTarget | ProbeTokenTarget;
type ProbeAccountContext = {
account: typeof schema.accounts.$inferSelect;
site: typeof schema.sites.$inferSelect;
};
export type ModelAvailabilityProbeAccountResult = {
accountId: number;
siteId: number;
status: 'success' | 'failed' | 'skipped';
scanned: number;
supported: number;
unsupported: number;
inconclusive: number;
skipped: number;
updatedRows: number;
message: string;
};
export type ModelAvailabilityProbeExecutionResult = {
results: ModelAvailabilityProbeAccountResult[];
summary: {
totalAccounts: number;
success: number;
failed: number;
skipped: number;
scanned: number;
supported: number;
unsupported: number;
inconclusive: number;
skippedModels: number;
updatedRows: number;
rebuiltRoutes: boolean;
};
};
let probeSchedulerTimer: ReturnType<typeof setInterval> | null = null;
const probeAccountLeases = new Set<number>();
async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const safeConcurrency = Math.max(1, Math.min(items.length || 1, Math.trunc(concurrency || 1)));
const results = new Array<R>(items.length);
let nextIndex = 0;
const runWorker = async () => {
while (true) {
const currentIndex = nextIndex;
nextIndex += 1;
if (currentIndex >= items.length) return;
results[currentIndex] = await worker(items[currentIndex] as T, currentIndex);
}
};
await Promise.all(Array.from({ length: safeConcurrency }, () => runWorker()));
return results;
}
async function probeSingleTarget(target: ProbeTarget): Promise<{
status: ProbeStatus;
latencyMs: number | null;
reason: string;
}> {
return await probeRuntimeModel({
site: target.site,
account: target.account,
modelName: target.modelName,
timeoutMs: config.modelAvailabilityProbeTimeoutMs,
tokenValue: target.kind === 'token' ? target.tokenValue : undefined,
});
}
async function updateProbeRow(target: ProbeTarget, status: ProbeStatus, latencyMs: number | null): Promise<{
touched: boolean;
availabilityChanged: boolean;
}> {
if (status === 'inconclusive' || status === 'skipped') {
return {
touched: false,
availabilityChanged: false,
};
}
const nextAvailable = status === 'supported';
const patch = {
available: nextAvailable,
latencyMs,
checkedAt: new Date().toISOString(),
};
if (target.kind === 'account') {
await db.update(schema.modelAvailability)
.set(patch)
.where(eq(schema.modelAvailability.id, target.rowId))
.run();
return {
touched: true,
availabilityChanged: target.lastKnownAvailable !== nextAvailable,
};
}
await db.update(schema.tokenModelAvailability)
.set(patch)
.where(eq(schema.tokenModelAvailability.id, target.rowId))
.run();
return {
touched: true,
availabilityChanged: target.lastKnownAvailable !== nextAvailable,
};
}
async function loadActiveProbeAccountContext(accountId: number): Promise<ProbeAccountContext | null> {
const accountRow = await db.select()
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
.get();
if (!accountRow) return null;
if ((accountRow.accounts.status || 'active') !== 'active') return null;
if ((accountRow.sites.status || 'active') !== 'active') return null;
return {
account: accountRow.accounts,
site: accountRow.sites,
};
}
async function loadProbeTargetsForAccount(context: ProbeAccountContext): Promise<ProbeTarget[]> {
const targets: ProbeTarget[] = [];
const accountModels = await db.select()
.from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, context.account.id))
.orderBy(asc(schema.modelAvailability.checkedAt))
.all();
for (const row of accountModels) {
if (row.isManual) continue;
targets.push({
kind: 'account',
rowId: row.id,
modelName: row.modelName,
lastKnownAvailable: !!row.available,
account: context.account,
site: context.site,
});
}
const tokenRows = await db.select()
.from(schema.tokenModelAvailability)
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.where(and(
eq(schema.accountTokens.accountId, context.account.id),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.orderBy(asc(schema.tokenModelAvailability.checkedAt))
.all();
for (const row of tokenRows) {
if (!isUsableAccountToken(row.account_tokens)) continue;
const tokenValue = String(row.account_tokens.token || '').trim();
if (!tokenValue) continue;
targets.push({
kind: 'token',
rowId: row.token_model_availability.id,
tokenId: row.account_tokens.id,
modelName: row.token_model_availability.modelName,
tokenValue,
lastKnownAvailable: !!row.token_model_availability.available,
account: context.account,
site: context.site,
});
}
return targets;
}
function tryAcquireProbeAccountLease(accountId: number): boolean {
if (!Number.isFinite(accountId) || accountId <= 0) return false;
if (probeAccountLeases.has(accountId)) return false;
probeAccountLeases.add(accountId);
return true;
}
function releaseProbeAccountLease(accountId: number): void {
probeAccountLeases.delete(accountId);
}
function buildSkippedProbeAccountResult(input: {
accountId: number;
siteId: number;
message: string;
}): ModelAvailabilityProbeAccountResult {
return {
accountId: input.accountId,
siteId: input.siteId,
status: 'skipped',
scanned: 0,
supported: 0,
unsupported: 0,
inconclusive: 0,
skipped: 0,
updatedRows: 0,
message: input.message,
};
}
function summarizeProbeResults(results: ModelAvailabilityProbeAccountResult[], rebuiltRoutes: boolean): ModelAvailabilityProbeExecutionResult {
return {
results,
summary: {
totalAccounts: results.length,
success: results.filter((item) => item.status === 'success').length,
failed: results.filter((item) => item.status === 'failed').length,
skipped: results.filter((item) => item.status === 'skipped').length,
scanned: results.reduce((sum, item) => sum + item.scanned, 0),
supported: results.reduce((sum, item) => sum + item.supported, 0),
unsupported: results.reduce((sum, item) => sum + item.unsupported, 0),
inconclusive: results.reduce((sum, item) => sum + item.inconclusive, 0),
skippedModels: results.reduce((sum, item) => sum + item.skipped, 0),
updatedRows: results.reduce((sum, item) => sum + item.updatedRows, 0),
rebuiltRoutes,
},
};
}
export async function executeModelAvailabilityProbe(input: {
accountId?: number;
rebuildRoutes?: boolean;
} = {}): Promise<ModelAvailabilityProbeExecutionResult> {
const accountIds = input.accountId
? [input.accountId]
: (await db.select({ id: schema.accounts.id })
.from(schema.accounts)
.where(eq(schema.accounts.status, 'active'))
.all()).map((row) => row.id);
const results: ModelAvailabilityProbeAccountResult[] = [];
let shouldRebuildRoutes = false;
for (const accountId of accountIds) {
const context = await loadActiveProbeAccountContext(accountId);
if (!context) {
continue;
}
if (!tryAcquireProbeAccountLease(accountId)) {
results.push(buildSkippedProbeAccountResult({
accountId,
siteId: context.site.id,
message: 'model availability probe already running for account',
}));
continue;
}
try {
const targets = await loadProbeTargetsForAccount(context);
if (targets.length <= 0) {
results.push(buildSkippedProbeAccountResult({
accountId,
siteId: context.site.id,
message: 'no discovered models to probe',
}));
continue;
}
let supported = 0;
let unsupported = 0;
let inconclusive = 0;
let skipped = 0;
let updatedRows = 0;
let failed = false;
const probeOutcomes = await mapWithConcurrency(
targets,
config.modelAvailabilityProbeConcurrency,
async (target) => {
try {
const probe = await probeSingleTarget(target);
const update = await updateProbeRow(target, probe.status, probe.latencyMs);
return {
target,
probe,
touched: update.touched,
availabilityChanged: update.availabilityChanged,
failed: false,
};
} catch (error) {
console.warn(`[model-probe] account ${accountId} model ${target.modelName} probe failed`, error);
return {
target,
probe: {
status: 'inconclusive' as const,
latencyMs: null,
reason: error instanceof Error ? error.message : 'probe failed',
},
touched: false,
availabilityChanged: false,
failed: true,
};
}
},
);
for (const outcome of probeOutcomes) {
if (outcome.probe.status === 'supported') supported += 1;
if (outcome.probe.status === 'unsupported') unsupported += 1;
if (outcome.probe.status === 'inconclusive') inconclusive += 1;
if (outcome.probe.status === 'skipped') skipped += 1;
if (outcome.touched) {
updatedRows += 1;
}
if (outcome.availabilityChanged) {
shouldRebuildRoutes = true;
}
if (outcome.failed) {
failed = true;
}
}
results.push({
accountId,
siteId: context.site.id,
status: failed ? 'failed' : 'success',
scanned: targets.length,
supported,
unsupported,
inconclusive,
skipped,
updatedRows,
message: failed
? 'model availability probe finished with partial failures'
: 'model availability probe finished',
});
} finally {
releaseProbeAccountLease(accountId);
}
}
let rebuiltRoutes = false;
if (input.rebuildRoutes !== false && shouldRebuildRoutes) {
await routeRefreshWorkflow.rebuildRoutesOnly();
rebuiltRoutes = true;
}
return summarizeProbeResults(results, rebuiltRoutes);
}
export function buildModelAvailabilityProbeTaskDedupeKey(accountId?: number | null): string {
const normalizedAccountId = Number.isFinite(accountId as number) && Number(accountId) > 0
? Math.trunc(Number(accountId))
: null;
return normalizedAccountId
? `model-availability-probe-${normalizedAccountId}`
: 'model-availability-probe-all';
}
export function queueModelAvailabilityProbeTask(input: {
accountId?: number;
title?: string;
}) {
const accountId = Number.isFinite(input.accountId as number) ? Math.trunc(input.accountId as number) : null;
const title = input.title || (accountId
? `探测模型可用性 #${accountId}`
: '探测模型可用性');
const dedupeKey = buildModelAvailabilityProbeTaskDedupeKey(accountId);
return startBackgroundTask(
{
type: 'model-probe',
title,
dedupeKey,
notifyOnFailure: true,
successMessage: (currentTask) => {
const summary = (currentTask.result as ModelAvailabilityProbeExecutionResult | undefined)?.summary;
if (!summary) return `${title}已完成`;
return `${title}完成:探测 ${summary.scanned},可用 ${summary.supported},不可用 ${summary.unsupported},不确定 ${summary.inconclusive}`;
},
failureMessage: (currentTask) => `${title}失败:${currentTask.error || 'unknown error'}`,
},
async () => executeModelAvailabilityProbe({
accountId: accountId ?? undefined,
rebuildRoutes: true,
}),
);
}
export function startModelAvailabilityProbeScheduler(intervalMs = config.modelAvailabilityProbeIntervalMs) {
stopModelAvailabilityProbeScheduler();
if (!config.modelAvailabilityProbeEnabled) {
return {
enabled: false,
intervalMs: 0,
};
}
const safeIntervalMs = Math.max(60_000, Math.trunc(intervalMs || 0));
probeSchedulerTimer = setInterval(() => {
void queueModelAvailabilityProbeTask({
title: '后台模型可用性探测',
});
}, safeIntervalMs);
probeSchedulerTimer.unref?.();
return {
enabled: true,
intervalMs: safeIntervalMs,
};
}
export function stopModelAvailabilityProbeScheduler() {
if (probeSchedulerTimer) {
clearInterval(probeSchedulerTimer);
probeSchedulerTimer = null;
}
}
export function __resetModelAvailabilityProbeExecutionStateForTests(): void {
probeAccountLeases.clear();
}
@@ -162,4 +162,71 @@ describe('proxyChannelCoordinator', () => {
second.lease.release();
}
});
it('exposes the set of currently active leased channels', async () => {
const lease = await proxyChannelCoordinator.acquireChannelLease({
channelId: 23,
accountExtraConfig: JSON.stringify({ credentialMode: 'session' }),
});
expect(lease.status).toBe('acquired');
if (lease.status !== 'acquired') return;
expect(proxyChannelCoordinator.getActiveChannelIds()).toEqual([23]);
lease.lease.release();
expect(proxyChannelCoordinator.getActiveChannelIds()).toEqual([]);
});
it('reports active and waiting load for a guarded session channel', async () => {
const first = await proxyChannelCoordinator.acquireChannelLease({
channelId: 31,
accountExtraConfig: JSON.stringify({ credentialMode: 'session' }),
});
expect(first.status).toBe('acquired');
if (first.status !== 'acquired') return;
const secondPromise = proxyChannelCoordinator.acquireChannelLease({
channelId: 31,
accountExtraConfig: JSON.stringify({ credentialMode: 'session' }),
});
await vi.advanceTimersByTimeAsync(0);
expect(proxyChannelCoordinator.getChannelLoadSnapshot({
channelId: 31,
accountExtraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toEqual({
channelId: 31,
sessionScoped: true,
concurrencyLimit: 1,
activeLeaseCount: 1,
waitingCount: 1,
loadRatio: 2,
saturated: true,
});
first.lease.release();
await vi.advanceTimersByTimeAsync(0);
const second = await secondPromise;
expect(second.status).toBe('acquired');
if (second.status === 'acquired') {
second.lease.release();
}
});
it('treats structured oauth providers as session-scoped in load snapshots', () => {
expect(proxyChannelCoordinator.getChannelLoadSnapshot({
channelId: 41,
accountExtraConfig: JSON.stringify({ credentialMode: 'session' }),
accountOauthProvider: 'codex',
})).toEqual({
channelId: 41,
sessionScoped: true,
concurrencyLimit: 1,
activeLeaseCount: 0,
waitingCount: 0,
loadRatio: 0,
saturated: false,
});
});
});
@@ -24,6 +24,16 @@ type ChannelRuntimeState = {
queue: ChannelWaiter[];
};
export type ProxyChannelLoadSnapshot = {
channelId: number;
sessionScoped: boolean;
concurrencyLimit: number;
activeLeaseCount: number;
waitingCount: number;
loadRatio: number;
saturated: boolean;
};
export type ProxyChannelLease = {
channelId: number;
isActive(): boolean;
@@ -104,9 +114,15 @@ function getOrCreateChannelRuntimeState(channelId: number): ChannelRuntimeState
return state;
}
function pruneCancelledWaiters(state: ChannelRuntimeState): void {
if (state.queue.length <= 0) return;
state.queue = state.queue.filter((waiter) => !waiter.cancelled);
}
function maybeDeleteChannelRuntimeState(channelId: number): void {
const state = channelRuntimeStates.get(channelId);
if (!state) return;
pruneCancelledWaiters(state);
if (state.activeLeaseIds.size <= 0 && state.queue.every((waiter) => waiter.cancelled)) {
channelRuntimeStates.delete(channelId);
}
@@ -177,6 +193,62 @@ class ProxyChannelCoordinator {
stickySessionBindings.delete(normalizedKey);
}
getActiveChannelIds(): number[] {
const ids: number[] = [];
for (const [channelId, state] of channelRuntimeStates.entries()) {
pruneCancelledWaiters(state);
if (state.activeLeaseIds.size > 0) {
ids.push(channelId);
}
}
return ids;
}
getChannelLoadSnapshot(input: {
channelId: number;
accountExtraConfig?: string | null;
accountOauthProvider?: string | null;
}): ProxyChannelLoadSnapshot {
const channelId = Math.trunc(input.channelId || 0);
const sessionScoped = isSessionScopedChannel({
extraConfig: input.accountExtraConfig,
oauthProvider: input.accountOauthProvider,
});
const concurrencyLimit = getChannelConcurrencyLimit({
extraConfig: input.accountExtraConfig,
oauthProvider: input.accountOauthProvider,
});
const state = channelId > 0 ? channelRuntimeStates.get(channelId) : null;
if (state) {
pruneCancelledWaiters(state);
}
const activeLeaseCount = state?.activeLeaseIds.size ?? 0;
const waitingCount = state?.queue.length ?? 0;
const denominator = concurrencyLimit > 0 ? concurrencyLimit : 1;
return {
channelId,
sessionScoped,
concurrencyLimit,
activeLeaseCount,
waitingCount,
loadRatio: (activeLeaseCount + waitingCount) / denominator,
saturated: concurrencyLimit > 0 && activeLeaseCount >= concurrencyLimit,
};
}
getChannelLoadSnapshots(input: Array<{
channelId: number;
accountExtraConfig?: string | null;
accountOauthProvider?: string | null;
}>): Map<number, ProxyChannelLoadSnapshot> {
const snapshots = new Map<number, ProxyChannelLoadSnapshot>();
for (const item of input) {
const snapshot = this.getChannelLoadSnapshot(item);
snapshots.set(snapshot.channelId, snapshot);
}
return snapshots;
}
async acquireChannelLease(input: {
channelId: number;
accountExtraConfig?: string | null;
@@ -202,6 +274,7 @@ class ProxyChannelCoordinator {
}
const state = getOrCreateChannelRuntimeState(channelId);
pruneCancelledWaiters(state);
if (state.activeLeaseIds.size < concurrencyLimit) {
return {
status: 'acquired',
@@ -226,6 +299,7 @@ class ProxyChannelCoordinator {
waiter.timer = setTimeout(() => {
waiter.cancelled = true;
waiter.timer = null;
pruneCancelledWaiters(state);
maybeDeleteChannelRuntimeState(channelId);
resolve({
status: 'timeout',
@@ -285,6 +359,7 @@ class ProxyChannelCoordinator {
private drainQueue(channelId: number): void {
const state = channelRuntimeStates.get(channelId);
if (!state) return;
pruneCancelledWaiters(state);
const concurrencyLimit = Math.max(0, Math.trunc(config.proxySessionChannelConcurrencyLimit || 0));
while (state.activeLeaseIds.size < concurrencyLimit && state.queue.length > 0) {
const waiter = state.queue.shift();
@@ -1,7 +1,7 @@
import { readRuntimeResponseText } from '../proxy-core/executors/types.js';
import type {
EndpointAttemptSuccessContext,
} from '../routes/proxy/endpointFlow.js';
} from '../proxy-core/orchestration/endpointFlow.js';
import {
finalizeProxyDebugTrace,
insertProxyDebugAttempt,
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { shouldRetryProxyRequest } from './proxyRetryPolicy.js';
import { shouldAbortSameSiteEndpointFallback, shouldRetryProxyRequest } from './proxyRetryPolicy.js';
describe('proxyRetryPolicy', () => {
it('retries on rate limit and server errors', () => {
@@ -52,4 +52,16 @@ describe('proxyRetryPolicy', () => {
shouldRetryProxyRequest(400, '{"error":{"message":"invalid timeout parameter"}}'),
).toBe(false);
});
it('aborts same-site endpoint fallback on rate-limit and quota responses', () => {
expect(
shouldAbortSameSiteEndpointFallback(429, '{"error":{"message":"rate limit exceeded"}}'),
).toBe(true);
expect(
shouldAbortSameSiteEndpointFallback(429, '{"error":{"message":"quota exceeded"}}'),
).toBe(true);
expect(
shouldAbortSameSiteEndpointFallback(429, '{"error":{"message":"too many requests"}}'),
).toBe(true);
});
});
+25
View File
@@ -9,6 +9,7 @@ const MODEL_UNSUPPORTED_PATTERNS: RegExp[] = [
/model.*does\s+not\s+exist/i,
/no\s+such\s+model/i,
/unknown\s+model/i,
/unknown\s+provider\s+for\s+model/i,
/invalid\s+model/i,
/model[_\s-]?not[_\s-]?found/i,
/you\s+do\s+not\s+have\s+access\s+to\s+the\s+model/i,
@@ -54,6 +55,23 @@ const NON_RETRYABLE_REQUEST_PATTERNS: RegExp[] = [
/unsupported\s+media\s+type/i,
];
const SAME_SITE_ENDPOINT_ABORT_PATTERNS: RegExp[] = [
/\b429\b/i,
/too\s+many\s+requests/i,
/rate\s+limit/i,
/quota(?:\s+exceeded)?/i,
/bad\s+gateway/i,
/gateway\s+time-?out/i,
/service\s+unavailable/i,
/temporar(?:y|ily)\s+unavailable/i,
/cpu\s+overloaded/i,
/connection\s+reset/i,
/connection\s+refused/i,
/econnreset/i,
/econnrefused/i,
...RETRYABLE_TIMEOUT_PATTERNS,
];
function isModelUnsupportedErrorMessage(rawMessage?: string | null): boolean {
const text = (rawMessage || '').trim();
if (!text) return false;
@@ -76,3 +94,10 @@ export function shouldRetryProxyRequest(status: number, upstreamErrorText?: stri
if (status === 400 || status === 404 || status === 422) return false;
return false;
}
export function shouldAbortSameSiteEndpointFallback(status: number, upstreamErrorText?: string | null): boolean {
if (status < 500 && status !== 408 && status !== 429) {
return false;
}
return matchesAnyPattern(SAME_SITE_ENDPOINT_ABORT_PATTERNS, upstreamErrorText);
}
+1
View File
@@ -0,0 +1 @@
export { dispatchRuntimeRequest } from '../routes/proxy/runtimeExecutor.js';
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const resolveUpstreamEndpointCandidatesMock = vi.fn();
const buildUpstreamEndpointRequestMock = vi.fn();
const dispatchRuntimeRequestMock = vi.fn();
const resolveChannelProxyUrlMock = vi.fn();
const withSiteRecordProxyRequestInitMock = vi.fn();
vi.mock('../routes/proxy/upstreamEndpoint.js', () => ({
resolveUpstreamEndpointCandidates: (...args: unknown[]) => resolveUpstreamEndpointCandidatesMock(...args),
buildUpstreamEndpointRequest: (...args: unknown[]) => buildUpstreamEndpointRequestMock(...args),
}));
vi.mock('../routes/proxy/runtimeExecutor.js', () => ({
dispatchRuntimeRequest: (...args: unknown[]) => dispatchRuntimeRequestMock(...args),
}));
vi.mock('./siteProxy.js', () => ({
resolveChannelProxyUrl: (...args: unknown[]) => resolveChannelProxyUrlMock(...args),
withSiteRecordProxyRequestInit: (...args: unknown[]) => withSiteRecordProxyRequestInitMock(...args),
}));
describe('probeRuntimeModel', () => {
const site = {
id: 1,
name: 'probe-site',
url: 'https://probe.example.com',
platform: 'new-api',
status: 'active',
} as any;
const account = {
id: 1,
siteId: 1,
username: 'probe-user',
accessToken: '',
apiToken: 'sk-probe',
status: 'active',
extraConfig: null,
} as any;
beforeEach(() => {
vi.resetModules();
resolveUpstreamEndpointCandidatesMock.mockReset();
buildUpstreamEndpointRequestMock.mockReset();
dispatchRuntimeRequestMock.mockReset();
resolveChannelProxyUrlMock.mockReset();
withSiteRecordProxyRequestInitMock.mockReset();
buildUpstreamEndpointRequestMock.mockReturnValue({
path: '/v1/chat/completions',
headers: { 'content-type': 'application/json' },
body: { model: 'gpt-5.4' },
runtime: {
executor: 'default',
modelName: 'gpt-5.4',
stream: false,
},
});
resolveChannelProxyUrlMock.mockReturnValue(null);
withSiteRecordProxyRequestInitMock.mockImplementation(async (_site: unknown, init: RequestInit) => init);
});
it('returns an inconclusive result instead of throwing when endpoint resolution fails', async () => {
resolveUpstreamEndpointCandidatesMock.mockRejectedValue(new Error('resolution failed'));
const { probeRuntimeModel } = await import('./runtimeModelProbe.js');
const result = await probeRuntimeModel({
site,
account,
modelName: 'gpt-5.4',
timeoutMs: 10,
});
expect(result.status).toBe('inconclusive');
expect(result.reason).toContain('resolution failed');
expect(result.latencyMs).not.toBeNull();
});
it('uses the remaining timeout budget for the runtime request phase', async () => {
resolveUpstreamEndpointCandidatesMock.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 15));
return ['chat'];
});
dispatchRuntimeRequestMock.mockImplementation(async (input: {
buildInit: (requestUrl: string, request: Record<string, unknown>) => Promise<RequestInit>;
request: Record<string, unknown>;
targetUrl?: string;
}) => {
const init = await input.buildInit(input.targetUrl || 'https://probe.example.com/v1/chat/completions', input.request);
const signal = init.signal as AbortSignal | undefined;
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, 40);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(signal.reason ?? new Error('aborted'));
}, { once: true });
});
return new Response(JSON.stringify({ ok: true }), { status: 200 });
});
const { probeRuntimeModel } = await import('./runtimeModelProbe.js');
const startedAt = Date.now();
const result = await probeRuntimeModel({
site,
account,
modelName: 'gpt-5.4',
timeoutMs: 30,
});
const elapsedMs = Date.now() - startedAt;
expect(result.status).toBe('inconclusive');
expect(result.latencyMs).not.toBeNull();
expect(elapsedMs).toBeLessThan(200);
});
});
+250
View File
@@ -0,0 +1,250 @@
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
import { buildOauthProviderHeaders } from './oauth/service.js';
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from './siteProxy.js';
import { dispatchRuntimeRequest } from './runtimeDispatch.js';
import {
buildUpstreamEndpointRequest,
resolveUpstreamEndpointCandidates,
type UpstreamEndpoint,
} from './upstreamEndpointRuntime.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from '../proxy-core/orchestration/endpointFlow.js';
import type { schema } from '../db/index.js';
export type RuntimeModelProbeStatus = 'supported' | 'unsupported' | 'inconclusive' | 'skipped';
export type RuntimeModelProbeResult = {
status: RuntimeModelProbeStatus;
latencyMs: number | null;
reason: string;
};
const NON_CONVERSATION_MODEL_PATTERNS = [
/(^|[-_/])embedding(s)?($|[-_/])/i,
/(^|[-_/])rerank($|[-_/])/i,
/(^|[-_/])moderation($|[-_/])/i,
/(^|[-_/])whisper($|[-_/])/i,
/(^|[-_/])tts($|[-_/])/i,
/(^|[-_/])transcribe|transcription/i,
/(^|[-_/])dall-e($|[-_/])/i,
/(^|[-_/])imagen($|[-_/])/i,
/(^|[-_/])veo($|[-_/])/i,
/(^|[-_/])cogvideo($|[-_/])/i,
];
const DEFINITE_UNSUPPORTED_PATTERNS = [
/no such model/i,
/unknown model/i,
/unsupported model/i,
/invalid model/i,
/model[^]{0,80}(does not exist|not found|not available|unavailable|unsupported|invalid|disabled)/i,
/(does not exist|not found|not available|unavailable|unsupported|invalid|disabled)[^]{0,40}model/i,
/模型[^]{0,40}(不存在|不可用|不支持|无效|禁用|未开通|未开放)/,
/(不存在|不可用|不支持|无效|禁用)[^]{0,20}模型/,
/model[^]{0,80}(access denied|permission|forbidden|not allowed)/i,
/模型[^]{0,40}(无权限|未授权|禁止访问)/,
];
function isLikelyConversationModel(modelName: string): boolean {
const normalized = String(modelName || '').trim();
if (!normalized) return false;
if (normalized.startsWith('__')) return false;
return !NON_CONVERSATION_MODEL_PATTERNS.some((pattern) => pattern.test(normalized));
}
function classifyUnsupportedFailure(status: number, rawErrorText: string): boolean {
if (![400, 403, 404, 422].includes(status)) return false;
const normalized = String(rawErrorText || '').trim();
if (!normalized) return false;
return DEFINITE_UNSUPPORTED_PATTERNS.some((pattern) => pattern.test(normalized));
}
function buildProbeBody(modelName: string): Record<string, unknown> {
return {
model: modelName,
messages: [
{
role: 'user',
content: 'Reply with OK.',
},
],
max_tokens: 8,
stream: false,
};
}
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
fn(),
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
timer.unref?.();
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function resolveRemainingTimeoutMs(deadlineAtMs: number, timeoutLabel: string): number {
const remainingMs = deadlineAtMs - Date.now();
if (remainingMs <= 0) {
throw new Error(timeoutLabel);
}
return remainingMs;
}
export async function probeRuntimeModel(input: {
site: typeof schema.sites.$inferSelect;
account: typeof schema.accounts.$inferSelect;
modelName: string;
timeoutMs: number;
tokenValue?: string | null;
}): Promise<RuntimeModelProbeResult> {
if (!isLikelyConversationModel(input.modelName)) {
return {
status: 'skipped',
latencyMs: null,
reason: 'skipped non-conversation model probe',
};
}
const oauth = getOauthInfoFromAccount(input.account);
const tokenValue = String(
input.tokenValue
|| (oauth ? input.account.accessToken : input.account.apiToken)
|| '',
).trim();
if (!tokenValue) {
return {
status: 'inconclusive',
latencyMs: null,
reason: 'missing credential for probe',
};
}
const startedAt = Date.now();
const deadlineAtMs = startedAt + Math.max(1, input.timeoutMs);
try {
const endpointCandidates = await withTimeout(
() => resolveUpstreamEndpointCandidates(
{
site: input.site,
account: input.account,
},
input.modelName,
'openai',
input.modelName,
),
resolveRemainingTimeoutMs(
deadlineAtMs,
`runtime model probe candidate resolution timeout (${Math.round(input.timeoutMs / 1000)}s)`,
),
`runtime model probe candidate resolution timeout (${Math.round(input.timeoutMs / 1000)}s)`,
);
if (endpointCandidates.length <= 0) {
return {
status: 'inconclusive',
latencyMs: Date.now() - startedAt,
reason: 'no compatible probe endpoint candidates',
};
}
const providerHeaders = buildOauthProviderHeaders({
account: input.account,
downstreamHeaders: {},
});
const openaiBody = buildProbeBody(input.modelName);
const channelProxyUrl = resolveChannelProxyUrl(input.site, input.account.extraConfig);
const abortController = new AbortController();
const remainingExecutionTimeoutMs = resolveRemainingTimeoutMs(
deadlineAtMs,
`runtime model probe timeout (${Math.round(input.timeoutMs / 1000)}s)`,
);
const abortTimer = setTimeout(() => {
abortController.abort(new Error(`runtime model probe timeout (${Math.round(input.timeoutMs / 1000)}s)`));
}, remainingExecutionTimeoutMs);
abortTimer.unref?.();
const buildRequest = (endpoint: UpstreamEndpoint): BuiltEndpointRequest => {
const request = buildUpstreamEndpointRequest({
endpoint,
modelName: input.modelName,
stream: false,
tokenValue,
oauthProvider: oauth?.provider,
oauthProjectId: oauth?.projectId,
sitePlatform: input.site.platform,
siteUrl: input.site.url,
openaiBody,
downstreamFormat: 'openai',
downstreamHeaders: {},
providerHeaders,
});
return {
endpoint,
path: request.path,
headers: request.headers,
body: request.body as Record<string, unknown>,
runtime: request.runtime,
};
};
const dispatchRequest = async (
request: BuiltEndpointRequest,
targetUrl: string,
) => (
dispatchRuntimeRequest({
siteUrl: input.site.url,
targetUrl,
request,
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(
input.site,
{
method: 'POST',
headers: requestForFetch.headers,
body: JSON.stringify(requestForFetch.body),
signal: abortController.signal,
},
channelProxyUrl,
),
})
);
let result: Awaited<ReturnType<typeof executeEndpointFlow>>;
try {
result = await executeEndpointFlow({
siteUrl: input.site.url,
proxyUrl: channelProxyUrl,
endpointCandidates,
buildRequest,
dispatchRequest,
});
} finally {
clearTimeout(abortTimer);
}
const latencyMs = Date.now() - startedAt;
if (result.ok) {
await result.upstream.text().catch(() => undefined);
return {
status: 'supported',
latencyMs,
reason: 'probe succeeded',
};
}
const rawErrorText = String(result.rawErrText || result.errText || '').trim();
return {
status: classifyUnsupportedFailure(result.status || 0, rawErrorText) ? 'unsupported' : 'inconclusive',
latencyMs,
reason: rawErrorText || `probe failed with status ${result.status || 0}`,
};
} catch (error) {
return {
status: 'inconclusive',
latencyMs: Date.now() - startedAt,
reason: error instanceof Error ? error.message : 'probe failed',
};
}
}
+85 -1
View File
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
type DbModule = typeof import('../db/index.js');
type TokenRouterModule = typeof import('./tokenRouter.js');
@@ -615,6 +615,90 @@ describe('TokenRouter runtime cache', () => {
expect(await router.selectChannel('gpt-4o-mini')).toBeNull();
});
it('clears short-window cooldown on sibling channels after a successful recovery probe', async () => {
const site = await db.insert(schema.sites).values({
name: 'shared-credential-recovery-site',
url: 'https://shared-credential-recovery.example.com',
platform: 'codex',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'shared-credential-recovery-user',
accessToken: 'shared-credential-recovery-access-token',
status: 'active',
oauthProvider: 'codex',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
},
}),
}).returning().get();
const primaryRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.4',
routingStrategy: 'weighted',
enabled: true,
}).returning().get();
const siblingRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
routingStrategy: 'weighted',
enabled: true,
}).returning().get();
const primaryChannel = await db.insert(schema.routeChannels).values({
routeId: primaryRoute.id,
accountId: account.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siblingChannel = await db.insert(schema.routeChannels).values({
routeId: siblingRoute.id,
accountId: account.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
await router.recordFailure(primaryChannel.id, {
status: 429,
errorText: JSON.stringify({
error: {
type: 'usage_limit_reached',
message: 'The usage limit has been reached',
},
}),
modelName: 'gpt-5.4',
});
await router.recordProbeSuccess(primaryChannel.id, 180, 'gpt-5.4');
const refreshedChannels = await db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.id, [primaryChannel.id, siblingChannel.id]))
.all();
expect(refreshedChannels).toEqual(expect.arrayContaining([
expect.objectContaining({
id: primaryChannel.id,
cooldownUntil: null,
consecutiveFailCount: 0,
cooldownLevel: 0,
}),
expect.objectContaining({
id: siblingChannel.id,
cooldownUntil: null,
consecutiveFailCount: 0,
cooldownLevel: 0,
}),
]));
});
it('round robins across all available channels regardless of priority', async () => {
const site = await db.insert(schema.sites).values({
name: 'round-robin-site',
@@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm';
type DbModule = typeof import('../db/index.js');
type TokenRouterModule = typeof import('./tokenRouter.js');
type ConfigModule = typeof import('../config.js');
type ProxyChannelCoordinatorModule = typeof import('./proxyChannelCoordinator.js');
const mockedCatalogRoutingCost = vi.fn<(
input: { siteId: number; accountId: number; modelName: string }
@@ -24,14 +25,20 @@ describe('TokenRouter selection scoring', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let TokenRouter: TokenRouterModule['TokenRouter'];
let tokenRouterTestUtils: TokenRouterModule['__tokenRouterTestUtils'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
let flushSiteRuntimeHealthPersistence: TokenRouterModule['flushSiteRuntimeHealthPersistence'];
let filterRecentlyFailedCandidates: TokenRouterModule['filterRecentlyFailedCandidates'];
let config: ConfigModule['config'];
let proxyChannelCoordinator: ProxyChannelCoordinatorModule['proxyChannelCoordinator'];
let resetProxyChannelCoordinatorState: ProxyChannelCoordinatorModule['resetProxyChannelCoordinatorState'];
let dataDir = '';
let idSeed = 0;
let originalRoutingWeights: typeof config.routingWeights;
let originalRoutingFallbackUnitCost: number;
let originalProxySessionChannelConcurrencyLimit: number;
let originalProxySessionChannelQueueWaitMs: number;
const nextId = () => {
idSeed += 1;
@@ -46,21 +53,30 @@ describe('TokenRouter selection scoring', () => {
const dbModule = await import('../db/index.js');
const tokenRouterModule = await import('./tokenRouter.js');
const configModule = await import('../config.js');
const coordinatorModule = await import('./proxyChannelCoordinator.js');
db = dbModule.db;
schema = dbModule.schema;
TokenRouter = tokenRouterModule.TokenRouter;
tokenRouterTestUtils = tokenRouterModule.__tokenRouterTestUtils;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
flushSiteRuntimeHealthPersistence = tokenRouterModule.flushSiteRuntimeHealthPersistence;
filterRecentlyFailedCandidates = tokenRouterModule.filterRecentlyFailedCandidates;
config = configModule.config;
proxyChannelCoordinator = coordinatorModule.proxyChannelCoordinator;
resetProxyChannelCoordinatorState = coordinatorModule.resetProxyChannelCoordinatorState;
originalRoutingWeights = { ...config.routingWeights };
originalRoutingFallbackUnitCost = config.routingFallbackUnitCost;
originalProxySessionChannelConcurrencyLimit = config.proxySessionChannelConcurrencyLimit;
originalProxySessionChannelQueueWaitMs = config.proxySessionChannelQueueWaitMs;
});
beforeEach(async () => {
idSeed = 0;
mockedCatalogRoutingCost.mockReset();
mockedCatalogRoutingCost.mockReturnValue(null);
config.proxySessionChannelConcurrencyLimit = originalProxySessionChannelConcurrencyLimit;
config.proxySessionChannelQueueWaitMs = originalProxySessionChannelQueueWaitMs;
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.settings).run();
@@ -69,13 +85,17 @@ describe('TokenRouter selection scoring', () => {
await db.delete(schema.sites).run();
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
resetProxyChannelCoordinatorState();
});
afterAll(() => {
config.routingWeights = { ...originalRoutingWeights };
config.routingFallbackUnitCost = originalRoutingFallbackUnitCost;
config.proxySessionChannelConcurrencyLimit = originalProxySessionChannelConcurrencyLimit;
config.proxySessionChannelQueueWaitMs = originalProxySessionChannelQueueWaitMs;
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
resetProxyChannelCoordinatorState();
delete process.env.DATA_DIR;
});
@@ -96,7 +116,7 @@ describe('TokenRouter selection scoring', () => {
}).returning().get();
}
async function createAccount(siteId: number, usernamePrefix: string) {
async function createAccount(siteId: number, usernamePrefix: string, options: { extraConfig?: string | null } = {}) {
const id = nextId();
return await db.insert(schema.accounts).values({
siteId,
@@ -104,6 +124,7 @@ describe('TokenRouter selection scoring', () => {
accessToken: `access-${id}`,
apiToken: `sk-${id}`,
status: 'active',
extraConfig: options.extraConfig ?? null,
}).returning().get();
}
@@ -126,7 +147,11 @@ describe('TokenRouter selection scoring', () => {
usageWeight: 0,
};
const route = await createRoute('gpt-5.2');
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.2',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const site = await createSite('sticky-site');
const account = await createAccount(site.id, 'sticky-user');
const tokenA = await createToken(account.id, 'sticky-a');
@@ -165,6 +190,27 @@ describe('TokenRouter selection scoring', () => {
await expect(router.selectPreferredChannel('gpt-5.2', preferredChannel.id)).resolves.toBeNull();
});
it('avoids recently failed candidates by default when healthy alternatives exist', () => {
const nowMs = Date.now();
const filtered = filterRecentlyFailedCandidates([
{
channel: {
failCount: 3,
lastFailAt: new Date(nowMs).toISOString(),
},
},
{
channel: {
failCount: 0,
lastFailAt: null,
},
},
], nowMs);
expect(filtered).toHaveLength(1);
expect(filtered[0]?.channel.failCount).toBe(0);
});
it('normalizes probability across channels on the same site', async () => {
config.routingWeights = {
baseWeightFactor: 1,
@@ -790,6 +836,66 @@ describe('TokenRouter selection scoring', () => {
expect(weakCandidate?.reason || '').toContain('成功率=40.0%');
});
it('stable_first ranks recent and fallback success rate ahead of balance-heavy weak sites', async () => {
config.routingWeights = {
baseWeightFactor: 0,
valueScoreFactor: 1,
costWeight: 0.1,
balanceWeight: 10,
usageWeight: 0,
};
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.4',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const siteStable = await createSite('stable-rate-front');
const accountStable = await createAccount(siteStable.id, 'stable-rate-user-front');
const tokenStable = await createToken(accountStable.id, 'stable-rate-token-front');
const stableChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountStable.id,
tokenId: tokenStable.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 80,
failCount: 4,
totalLatencyMs: 80 * 380,
}).returning().get();
const siteWeak = await createSite('stable-rate-back');
const accountWeak = await createAccount(siteWeak.id, 'stable-rate-user-back');
await db.update(schema.accounts).set({
balance: 999999,
}).where(eq(schema.accounts.id, accountWeak.id)).run();
const tokenWeak = await createToken(accountWeak.id, 'stable-rate-token-back');
const weakChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountWeak.id,
tokenId: tokenWeak.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 5,
failCount: 21,
totalLatencyMs: 5 * 1600,
}).returning().get();
const decision = await new TokenRouter().explainSelection('gpt-5.4');
const stableCandidate = decision.candidates.find((candidate) => candidate.channelId === stableChannel.id);
const weakCandidate = decision.candidates.find((candidate) => candidate.channelId === weakChannel.id);
expect(stableCandidate).toBeTruthy();
expect(weakCandidate).toBeTruthy();
expect(decision.selectedChannelId).toBe(stableChannel.id);
expect((stableCandidate?.probability || 0)).toBeGreaterThan(weakCandidate?.probability || 0);
expect(stableCandidate?.reason || '').toContain('近期成功率=');
expect(weakCandidate?.reason || '').toContain('综合近期成功率=');
});
it('reloads persisted runtime health after in-memory reset', async () => {
config.routingWeights = {
baseWeightFactor: 1,
@@ -856,6 +962,79 @@ describe('TokenRouter selection scoring', () => {
expect(candidateA?.reason || '').toContain('运行时健康=');
});
it('keeps a recovered stable_first site behind healthier peers until recent success rebuilds', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.3',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const siteRecovered = await createSite('stable-recovery-a');
const accountRecovered = await createAccount(siteRecovered.id, 'stable-recovery-user-a');
const tokenRecovered = await createToken(accountRecovered.id, 'stable-recovery-token-a');
const recoveredChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountRecovered.id,
tokenId: tokenRecovered.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteHealthy = await createSite('stable-recovery-b');
const accountHealthy = await createAccount(siteHealthy.id, 'stable-recovery-user-b');
const tokenHealthy = await createToken(accountHealthy.id, 'stable-recovery-token-b');
const healthyChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountHealthy.id,
tokenId: tokenHealthy.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
for (let index = 0; index < 3; index += 1) {
await router.recordFailure(recoveredChannel.id, {
status: 502,
errorText: 'Gateway timeout',
modelName: 'gpt-5.3',
});
}
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
consecutiveFailCount: 0,
cooldownLevel: 0,
}).where(eq(schema.routeChannels.id, recoveredChannel.id)).run();
await router.recordSuccess(recoveredChannel.id, 900, 0, 'gpt-5.3');
for (let index = 0; index < 4; index += 1) {
await router.recordSuccess(healthyChannel.id, 320, 0, 'gpt-5.3');
}
invalidateTokenRouterCache();
const preview = await router.previewSelectedChannel('gpt-5.3');
const decision = await router.explainSelection('gpt-5.3');
const recoveredCandidate = decision.candidates.find((candidate) => candidate.channelId === recoveredChannel.id);
const healthyCandidate = decision.candidates.find((candidate) => candidate.channelId === healthyChannel.id);
expect(preview?.channel.id).toBe(healthyChannel.id);
expect(decision.selectedChannelId).toBe(healthyChannel.id);
expect((recoveredCandidate?.probability || 0)).toBeLessThan(healthyCandidate?.probability || 0);
expect(recoveredCandidate?.reason || '').toContain('近期成功率=');
expect(healthyCandidate?.reason || '').toContain('近期成功率=');
});
it('penalizes the failed model more than unrelated models on the same site', async () => {
config.routingWeights = {
baseWeightFactor: 1,
@@ -934,6 +1113,90 @@ describe('TokenRouter selection scoring', () => {
expect(gptCandidateA?.reason || '').toContain('模型=');
});
it('treats unknown provider for model as model-scoped degradation instead of opening a site breaker', 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('unknown-provider-a');
const accountA = await createAccount(siteA.id, 'unknown-provider-user-a');
const tokenA = await createToken(accountA.id, 'unknown-provider-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('unknown-provider-b');
const accountB = await createAccount(siteB.id, 'unknown-provider-user-b');
const tokenB = await createToken(accountB.id, 'unknown-provider-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();
for (let index = 0; index < 3; index += 1) {
await router.recordFailure(gptChannelA.id, {
status: 502,
errorText: 'unknown provider for model gpt-5.4',
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('unknown-provider-a'));
const claudeCandidateA = claudeDecision.candidates.find((candidate) => candidate.siteName.startsWith('unknown-provider-a'));
expect(gptCandidateA).toBeTruthy();
expect(claudeCandidateA).toBeTruthy();
expect(gptDecision.summary.join(' ')).not.toContain('站点熔断避让');
expect(claudeDecision.summary.join(' ')).not.toContain('站点熔断避让');
expect(gptCandidateA?.reason || '').not.toContain('站点熔断');
expect(claudeCandidateA?.reason || '').not.toContain('站点熔断');
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,
@@ -993,4 +1256,282 @@ describe('TokenRouter selection scoring', () => {
expect(decision.summary.join(' ')).toContain('稳定优先');
expect(decision.selectedChannelId).toBe(channelB.id);
});
it('stable_first rotates across sites that remain inside the stable pool', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.4',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const siteA = await createSite('stable-pool-a');
const accountA = await createAccount(siteA.id, 'stable-pool-user-a');
const tokenA = await createToken(accountA.id, 'stable-pool-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-pool-b');
const accountB = await createAccount(siteB.id, 'stable-pool-user-b');
const tokenB = await createToken(accountB.id, 'stable-pool-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 9,
enabled: true,
}).returning().get();
const router = new TokenRouter();
const first = await router.selectChannel('gpt-5.4');
const second = await router.selectChannel('gpt-5.4');
const third = await router.selectChannel('gpt-5.4');
const decision = await router.explainSelection('gpt-5.4');
expect(first?.channel.id).toBe(channelA.id);
expect(second?.channel.id).toBe(channelB.id);
expect(third?.channel.id).toBe(channelA.id);
expect(decision.summary.join(' ')).toContain('主池站点 2');
});
it('stable_first rotates across healthy sites in configured priority order instead of stopping at the first layer', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4.1',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const siteA = await createSite('ordered-stable-a');
const accountA = await createAccount(siteA.id, 'ordered-stable-user-a');
const tokenA = await createToken(accountA.id, 'ordered-stable-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('ordered-stable-b');
const accountB = await createAccount(siteB.id, 'ordered-stable-user-b');
const tokenB = await createToken(accountB.id, 'ordered-stable-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 4,
weight: 10,
enabled: true,
}).returning().get();
const siteC = await createSite('ordered-stable-c');
const accountC = await createAccount(siteC.id, 'ordered-stable-user-c');
const tokenC = await createToken(accountC.id, 'ordered-stable-token-c');
const channelC = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountC.id,
tokenId: tokenC.id,
priority: 8,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
const first = await router.selectChannel('gpt-4.1');
const second = await router.selectChannel('gpt-4.1');
const third = await router.selectChannel('gpt-4.1');
const fourth = await router.selectChannel('gpt-4.1');
const decision = await router.explainSelection('gpt-4.1');
expect(first?.channel.id).toBe(channelA.id);
expect(second?.channel.id).toBe(channelB.id);
expect(third?.channel.id).toBe(channelC.id);
expect(fourth?.channel.id).toBe(channelA.id);
expect(decision.summary.join(' ')).toContain('按配置顺序轮询站点');
});
it('stable_first gives observation-pool sites occasional real traffic without background probing', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.4-probe-free',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const sitePrimary = await createSite('observation-primary');
const accountPrimary = await createAccount(sitePrimary.id, 'observation-user-primary');
const tokenPrimary = await createToken(accountPrimary.id, 'observation-token-primary');
const primaryChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountPrimary.id,
tokenId: tokenPrimary.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 48,
failCount: 0,
totalLatencyMs: 48 * 320,
}).returning().get();
const siteObservation = await createSite('observation-candidate');
const accountObservation = await createAccount(siteObservation.id, 'observation-user-candidate');
const tokenObservation = await createToken(accountObservation.id, 'observation-token-candidate');
const observationChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountObservation.id,
tokenId: tokenObservation.id,
priority: 1,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
const selectedChannelIds: number[] = [];
for (let index = 0; index < 23; index += 1) {
const selected = await router.selectChannel('gpt-5.4-probe-free');
selectedChannelIds.push(selected?.channel.id ?? 0);
}
let decision = await router.explainSelection('gpt-5.4-probe-free');
let observationCandidate = decision.candidates.find((candidate) => candidate.channelId === observationChannel.id);
let primaryCandidate = decision.candidates.find((candidate) => candidate.channelId === primaryChannel.id);
expect(observationCandidate?.probability).toBe(100);
expect(primaryCandidate?.probability).toBe(0);
expect(decision.summary.join(' ')).toContain('本次命中观察池灰度流量');
const observationSelected = await router.selectChannel('gpt-5.4-probe-free');
selectedChannelIds.push(observationSelected?.channel.id ?? 0);
decision = await router.explainSelection('gpt-5.4-probe-free');
observationCandidate = decision.candidates.find((candidate) => candidate.channelId === observationChannel.id);
primaryCandidate = decision.candidates.find((candidate) => candidate.channelId === primaryChannel.id);
expect(selectedChannelIds.filter((channelId) => channelId === observationChannel.id)).toHaveLength(1);
expect(selectedChannelIds.filter((channelId) => channelId === primaryChannel.id).length).toBeGreaterThan(20);
expect(decision.summary.join(' ')).toContain('观察池站点 1');
expect(decision.summary.join(' ')).toContain('还需 23 次主池请求');
expect(observationCandidate?.reason || '').toContain('观察池');
expect(observationCandidate?.probability).toBe(0);
expect(primaryCandidate?.reason || '').toContain('主池');
expect((primaryCandidate?.probability || 0)).toBeGreaterThan(0);
});
it('caps the stable_first rotation cache size', () => {
invalidateTokenRouterCache();
for (let index = 0; index < 1200; index += 1) {
tokenRouterTestUtils.rememberStableFirstSiteSelectionForKey(`route:${index}`, (index % 7) + 1);
}
expect(tokenRouterTestUtils.getStableFirstRotationCacheSize()).toBeLessThanOrEqual(1024);
});
it('penalizes saturated session-scoped channels using runtime load snapshots', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
config.proxySessionChannelConcurrencyLimit = 1;
config.proxySessionChannelQueueWaitMs = 5_000;
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.2',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const sessionExtraConfig = JSON.stringify({ credentialMode: 'session' });
const siteBusy = await createSite('runtime-load-busy');
const accountBusy = await createAccount(siteBusy.id, 'runtime-load-user-busy', {
extraConfig: sessionExtraConfig,
});
const tokenBusy = await createToken(accountBusy.id, 'runtime-load-token-busy');
const channelBusy = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountBusy.id,
tokenId: tokenBusy.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteFree = await createSite('runtime-load-free');
const accountFree = await createAccount(siteFree.id, 'runtime-load-user-free', {
extraConfig: sessionExtraConfig,
});
const tokenFree = await createToken(accountFree.id, 'runtime-load-token-free');
const channelFree = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountFree.id,
tokenId: tokenFree.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const activeLease = await proxyChannelCoordinator.acquireChannelLease({
channelId: channelBusy.id,
accountExtraConfig: accountBusy.extraConfig,
});
expect(activeLease.status).toBe('acquired');
if (activeLease.status !== 'acquired') return;
const queuedLeasePromise = proxyChannelCoordinator.acquireChannelLease({
channelId: channelBusy.id,
accountExtraConfig: accountBusy.extraConfig,
});
await Promise.resolve();
const router = new TokenRouter();
const preview = await router.previewSelectedChannel('gpt-5.2');
const decision = await router.explainSelection('gpt-5.2');
const busyCandidate = decision.candidates.find((candidate) => candidate.channelId === channelBusy.id);
const freeCandidate = decision.candidates.find((candidate) => candidate.channelId === channelFree.id);
expect(preview?.channel.id).toBe(channelFree.id);
expect(busyCandidate?.reason || '').toContain('会话负载=');
expect(busyCandidate?.reason || '').toContain('活跃=1/1');
expect(busyCandidate?.reason || '').toContain('等待=1');
expect((busyCandidate?.probability || 0)).toBeLessThan((freeCandidate?.probability || 0));
activeLease.lease.release();
const queuedLease = await queuedLeasePromise;
expect(queuedLease.status).toBe('acquired');
if (queuedLease.status === 'acquired') {
queuedLease.lease.release();
}
});
});
+2 -2
View File
@@ -81,7 +81,7 @@ describe('filterRecentlyFailedCandidates', () => {
expect(result.map((c) => c.id).sort()).toEqual(['a', 'b']);
});
it('keeps candidates unchanged when avoidSec is omitted', () => {
it('uses the default recent-failure backoff when avoidSec is omitted', () => {
const nowMs = Date.now();
const candidates: Candidate[] = [
{
@@ -101,7 +101,7 @@ describe('filterRecentlyFailedCandidates', () => {
];
const result = filterRecentlyFailedCandidates(candidates, nowMs);
expect(result).toEqual(candidates);
expect(result.map((candidate) => candidate.id)).toEqual(['healthy']);
});
it('does not penalize stale failures outside the avoidance window', () => {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
export {
buildClaudeCountTokensUpstreamRequest,
buildUpstreamEndpointRequest,
resolveUpstreamEndpointCandidates,
type UpstreamEndpoint,
} from '../routes/proxy/upstreamEndpoint.js';
@@ -33,6 +33,7 @@ export type EndpointCapabilityProfile = {
hasNonImageFileInput: boolean;
hasRemoteDocumentUrl: boolean;
wantsNativeResponsesReasoning: boolean;
wantsContinuationAwareResponses: boolean;
};
type EndpointRuntimeState = {
@@ -88,6 +89,7 @@ export function buildEndpointCapabilityProfile(input?: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
wantsContinuationAwareResponses?: boolean;
};
}): EndpointCapabilityProfile {
const conversationFileSummary = input?.requestCapabilities?.conversationFileSummary;
@@ -107,6 +109,7 @@ export function buildEndpointCapabilityProfile(input?: {
conversationFileSummary?.hasRemoteDocumentUrl === true
),
wantsNativeResponsesReasoning: input?.requestCapabilities?.wantsNativeResponsesReasoning === true,
wantsContinuationAwareResponses: input?.requestCapabilities?.wantsContinuationAwareResponses === true,
};
}
@@ -131,6 +134,7 @@ function buildEndpointRuntimeStateKey(input: {
capabilityProfile.hasNonImageFileInput ? 'files' : 'nofiles',
capabilityProfile.hasRemoteDocumentUrl ? 'remoteurl' : 'noremoteurl',
capabilityProfile.wantsNativeResponsesReasoning ? 'reasoning' : 'noreasoning',
capabilityProfile.wantsContinuationAwareResponses ? 'continuation' : 'nocontinuation',
].join(':');
}
@@ -247,6 +251,7 @@ export function getUpstreamEndpointRuntimeStateSnapshot(input: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
wantsContinuationAwareResponses?: boolean;
};
}) {
const capabilityProfile = buildEndpointCapabilityProfile({
@@ -349,6 +354,7 @@ export function recordUpstreamEndpointSuccess(input: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
wantsContinuationAwareResponses?: boolean;
};
}): UpstreamEndpointRuntimeMemoryWrite | null {
const capabilityProfile = buildEndpointCapabilityProfile({
@@ -390,6 +396,7 @@ export function recordUpstreamEndpointFailure(input: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
wantsContinuationAwareResponses?: boolean;
};
}): UpstreamEndpointRuntimeMemoryWrite | null {
const capabilityProfile = buildEndpointCapabilityProfile({