[codex] Add route cooldown controls (#365)
* Add route cooldown controls * Address route cooldown review comments
This commit is contained in:
@@ -7,6 +7,7 @@ const DEFAULT_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|||||||
const DEFAULT_CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
const DEFAULT_CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
||||||
const DEFAULT_GEMINI_CLI_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
|
const DEFAULT_GEMINI_CLI_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
|
||||||
const DEFAULT_GEMINI_CLI_CLIENT_SECRET = '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';
|
const DEFAULT_GEMINI_CLI_CLIENT_SECRET = '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';
|
||||||
|
export const TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC_CEILING = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
function parseBoolean(value: string | undefined, fallback = false): boolean {
|
function parseBoolean(value: string | undefined, fallback = false): boolean {
|
||||||
if (value === undefined) return fallback;
|
if (value === undefined) return fallback;
|
||||||
@@ -49,6 +50,15 @@ function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres'
|
|||||||
return 'sqlite';
|
return 'sqlite';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeTokenRouterFailureCooldownMaxSec(value: unknown): number | null {
|
||||||
|
const normalized = Number(value);
|
||||||
|
if (!Number.isFinite(normalized) || normalized <= 0) return null;
|
||||||
|
return Math.min(
|
||||||
|
TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC_CEILING,
|
||||||
|
Math.max(1, Math.trunc(normalized)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function parseListenHost(env: NodeJS.ProcessEnv): string {
|
function parseListenHost(env: NodeJS.ProcessEnv): string {
|
||||||
return (env.HOST || '0.0.0.0').trim() || '0.0.0.0';
|
return (env.HOST || '0.0.0.0').trim() || '0.0.0.0';
|
||||||
}
|
}
|
||||||
@@ -108,6 +118,9 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
|
|||||||
dbSsl: parseBoolean(env.DB_SSL, false),
|
dbSsl: parseBoolean(env.DB_SSL, false),
|
||||||
requestBodyLimit: DEFAULT_REQUEST_BODY_LIMIT,
|
requestBodyLimit: DEFAULT_REQUEST_BODY_LIMIT,
|
||||||
routingFallbackUnitCost: Math.max(1e-6, parseNumber(env.ROUTING_FALLBACK_UNIT_COST, 1)),
|
routingFallbackUnitCost: Math.max(1e-6, parseNumber(env.ROUTING_FALLBACK_UNIT_COST, 1)),
|
||||||
|
tokenRouterFailureCooldownMaxSec: normalizeTokenRouterFailureCooldownMaxSec(
|
||||||
|
parseNumber(env.TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC, TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC_CEILING),
|
||||||
|
) ?? TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC_CEILING,
|
||||||
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
|
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
|
||||||
proxyMaxChannelAttempts: Math.max(1, Math.trunc(parseNumber(env.PROXY_MAX_CHANNEL_ATTEMPTS, 3))),
|
proxyMaxChannelAttempts: Math.max(1, Math.trunc(parseNumber(env.PROXY_MAX_CHANNEL_ATTEMPTS, 3))),
|
||||||
proxyStickySessionEnabled: parseBoolean(env.PROXY_STICKY_SESSION_ENABLED, true),
|
proxyStickySessionEnabled: parseBoolean(env.PROXY_STICKY_SESSION_ENABLED, true),
|
||||||
|
|||||||
+11
-1
@@ -1,7 +1,11 @@
|
|||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import { buildFastifyOptions, config } from './config.js';
|
import {
|
||||||
|
buildFastifyOptions,
|
||||||
|
config,
|
||||||
|
normalizeTokenRouterFailureCooldownMaxSec,
|
||||||
|
} from './config.js';
|
||||||
import { normalizePayloadRulesConfig } from './services/payloadRules.js';
|
import { normalizePayloadRulesConfig } from './services/payloadRules.js';
|
||||||
import { authMiddleware } from './middleware/auth.js';
|
import { authMiddleware } from './middleware/auth.js';
|
||||||
import { sitesRoutes } from './routes/api/sites.js';
|
import { sitesRoutes } from './routes/api/sites.js';
|
||||||
@@ -287,6 +291,12 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
|
|||||||
config.routingFallbackUnitCost = Math.max(1e-6, routingFallbackUnitCost);
|
config.routingFallbackUnitCost = Math.max(1e-6, routingFallbackUnitCost);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenRouterFailureCooldownMaxSec = parseSettingFromMap<number>(settingsMap, 'token_router_failure_cooldown_max_sec');
|
||||||
|
const normalizedFailureCooldownMaxSec = normalizeTokenRouterFailureCooldownMaxSec(tokenRouterFailureCooldownMaxSec);
|
||||||
|
if (normalizedFailureCooldownMaxSec != null) {
|
||||||
|
config.tokenRouterFailureCooldownMaxSec = normalizedFailureCooldownMaxSec;
|
||||||
|
}
|
||||||
|
|
||||||
const webhookUrl = parseSettingFromMap<string>(settingsMap, 'webhook_url');
|
const webhookUrl = parseSettingFromMap<string>(settingsMap, 'webhook_url');
|
||||||
if (typeof webhookUrl === 'string') config.webhookUrl = webhookUrl;
|
if (typeof webhookUrl === 'string') config.webhookUrl = webhookUrl;
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ describe('settings and auth events', () => {
|
|||||||
(config as any).proxyDebugRetentionHours = 24;
|
(config as any).proxyDebugRetentionHours = 24;
|
||||||
(config as any).proxyDebugMaxBodyBytes = 262144;
|
(config as any).proxyDebugMaxBodyBytes = 262144;
|
||||||
config.routingFallbackUnitCost = 1;
|
config.routingFallbackUnitCost = 1;
|
||||||
|
(config as any).tokenRouterFailureCooldownMaxSec = 30 * 24 * 60 * 60;
|
||||||
(config as any).disableCrossProtocolFallback = false;
|
(config as any).disableCrossProtocolFallback = false;
|
||||||
(config as any).telegramEnabled = false;
|
(config as any).telegramEnabled = false;
|
||||||
(config as any).telegramApiBaseUrl = 'https://api.telegram.org';
|
(config as any).telegramApiBaseUrl = 'https://api.telegram.org';
|
||||||
@@ -461,6 +462,52 @@ describe('settings and auth events', () => {
|
|||||||
expect(runtime.routingFallbackUnitCost).toBe(0.25);
|
expect(runtime.routingFallbackUnitCost).toBe(0.25);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists and returns token router failure cooldown cap from runtime settings', async () => {
|
||||||
|
const updateResponse = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings/runtime',
|
||||||
|
payload: {
|
||||||
|
tokenRouterFailureCooldownMaxSec: 2 * 24 * 60 * 60,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateResponse.statusCode).toBe(200);
|
||||||
|
const updated = updateResponse.json() as { tokenRouterFailureCooldownMaxSec?: number };
|
||||||
|
expect(updated.tokenRouterFailureCooldownMaxSec).toBe(2 * 24 * 60 * 60);
|
||||||
|
expect((config as any).tokenRouterFailureCooldownMaxSec).toBe(2 * 24 * 60 * 60);
|
||||||
|
|
||||||
|
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'token_router_failure_cooldown_max_sec')).get();
|
||||||
|
expect(saved?.value).toBe(JSON.stringify(2 * 24 * 60 * 60));
|
||||||
|
|
||||||
|
const getResponse = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/settings/runtime',
|
||||||
|
});
|
||||||
|
expect(getResponse.statusCode).toBe(200);
|
||||||
|
const runtime = getResponse.json() as { tokenRouterFailureCooldownMaxSec?: number };
|
||||||
|
expect(runtime.tokenRouterFailureCooldownMaxSec).toBe(2 * 24 * 60 * 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps token router failure cooldown cap to the supported ceiling', async () => {
|
||||||
|
const ninetyDaysSec = 90 * 24 * 60 * 60;
|
||||||
|
const thirtyDaysSec = 30 * 24 * 60 * 60;
|
||||||
|
const updateResponse = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings/runtime',
|
||||||
|
payload: {
|
||||||
|
tokenRouterFailureCooldownMaxSec: ninetyDaysSec,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateResponse.statusCode).toBe(200);
|
||||||
|
const updated = updateResponse.json() as { tokenRouterFailureCooldownMaxSec?: number };
|
||||||
|
expect(updated.tokenRouterFailureCooldownMaxSec).toBe(thirtyDaysSec);
|
||||||
|
expect((config as any).tokenRouterFailureCooldownMaxSec).toBe(thirtyDaysSec);
|
||||||
|
|
||||||
|
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'token_router_failure_cooldown_max_sec')).get();
|
||||||
|
expect(saved?.value).toBe(JSON.stringify(thirtyDaysSec));
|
||||||
|
});
|
||||||
|
|
||||||
it('persists and returns disable cross protocol fallback from runtime settings', async () => {
|
it('persists and returns disable cross protocol fallback from runtime settings', async () => {
|
||||||
const updateResponse = await app.inject({
|
const updateResponse = await app.inject({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { config } from '../../config.js';
|
import { config, normalizeTokenRouterFailureCooldownMaxSec } from '../../config.js';
|
||||||
import { db, runtimeDbDialect, schema } from '../../db/index.js';
|
import { db, runtimeDbDialect, schema } from '../../db/index.js';
|
||||||
import { upsertSetting } from '../../db/upsertSetting.js';
|
import { upsertSetting } from '../../db/upsertSetting.js';
|
||||||
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
@@ -90,6 +90,7 @@ interface RuntimeSettingsBody {
|
|||||||
notifyCooldownSec?: number;
|
notifyCooldownSec?: number;
|
||||||
adminIpAllowlist?: string[] | string;
|
adminIpAllowlist?: string[] | string;
|
||||||
routingFallbackUnitCost?: number;
|
routingFallbackUnitCost?: number;
|
||||||
|
tokenRouterFailureCooldownMaxSec?: number;
|
||||||
routingWeights?: Partial<RoutingWeights>;
|
routingWeights?: Partial<RoutingWeights>;
|
||||||
proxyErrorKeywords?: string[] | string;
|
proxyErrorKeywords?: string[] | string;
|
||||||
proxyEmptyContentFailEnabled?: boolean;
|
proxyEmptyContentFailEnabled?: boolean;
|
||||||
@@ -665,6 +666,12 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
|||||||
config.routingFallbackUnitCost = Math.max(1e-6, n);
|
config.routingFallbackUnitCost = Math.max(1e-6, n);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'token_router_failure_cooldown_max_sec': {
|
||||||
|
const normalized = normalizeTokenRouterFailureCooldownMaxSec(value);
|
||||||
|
if (normalized == null) return;
|
||||||
|
config.tokenRouterFailureCooldownMaxSec = normalized;
|
||||||
|
return;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -694,6 +701,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
|||||||
proxyDebugRetentionHours: config.proxyDebugRetentionHours,
|
proxyDebugRetentionHours: config.proxyDebugRetentionHours,
|
||||||
proxyDebugMaxBodyBytes: config.proxyDebugMaxBodyBytes,
|
proxyDebugMaxBodyBytes: config.proxyDebugMaxBodyBytes,
|
||||||
routingFallbackUnitCost: config.routingFallbackUnitCost,
|
routingFallbackUnitCost: config.routingFallbackUnitCost,
|
||||||
|
tokenRouterFailureCooldownMaxSec: config.tokenRouterFailureCooldownMaxSec,
|
||||||
routingWeights: config.routingWeights,
|
routingWeights: config.routingWeights,
|
||||||
webhookUrl: config.webhookUrl,
|
webhookUrl: config.webhookUrl,
|
||||||
barkUrl: config.barkUrl,
|
barkUrl: config.barkUrl,
|
||||||
@@ -1585,6 +1593,18 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
upsertSetting('routing_fallback_unit_cost', normalized);
|
upsertSetting('routing_fallback_unit_cost', normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.tokenRouterFailureCooldownMaxSec !== undefined) {
|
||||||
|
const normalized = normalizeTokenRouterFailureCooldownMaxSec(body.tokenRouterFailureCooldownMaxSec);
|
||||||
|
if (normalized == null) {
|
||||||
|
return reply.code(400).send({ success: false, message: '路由失败冷却上限必须是大于 0 的数字(秒)' });
|
||||||
|
}
|
||||||
|
if (normalized !== config.tokenRouterFailureCooldownMaxSec) {
|
||||||
|
changedLabels.push(`路由失败冷却上限(${config.tokenRouterFailureCooldownMaxSec}s -> ${normalized}s)`);
|
||||||
|
}
|
||||||
|
config.tokenRouterFailureCooldownMaxSec = normalized;
|
||||||
|
upsertSetting('token_router_failure_cooldown_max_sec', normalized);
|
||||||
|
}
|
||||||
|
|
||||||
if (changedLabels.length > 0) {
|
if (changedLabels.length > 0) {
|
||||||
let eventType: 'checkin' | 'balance' | 'proxy' | 'status' | 'token' = 'status';
|
let eventType: 'checkin' | 'balance' | 'proxy' | 'status' | 'token' = 'status';
|
||||||
if (changedLabels.length === 1) {
|
if (changedLabels.length === 1) {
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
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';
|
||||||
|
|
||||||
|
type DbModule = typeof import('../../db/index.js');
|
||||||
|
|
||||||
|
describe('POST /api/routes/:id/cooldown/clear', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let dataDir = '';
|
||||||
|
let seedId = 0;
|
||||||
|
|
||||||
|
const nextId = () => {
|
||||||
|
seedId += 1;
|
||||||
|
return seedId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seedAccountWithToken = async () => {
|
||||||
|
const id = nextId();
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: `cooldown-site-${id}`,
|
||||||
|
url: `https://cooldown-site-${id}.example.com`,
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: `cooldown-user-${id}`,
|
||||||
|
accessToken: `cooldown-access-token-${id}`,
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const token = await db.insert(schema.accountTokens).values({
|
||||||
|
accountId: account.id,
|
||||||
|
name: `cooldown-token-${id}`,
|
||||||
|
token: `sk-cooldown-token-${id}`,
|
||||||
|
enabled: true,
|
||||||
|
isDefault: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
return { site, account, token };
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-route-cooldown-clear-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
|
||||||
|
await import('../../db/migrate.js');
|
||||||
|
const dbModule = await import('../../db/index.js');
|
||||||
|
const routesModule = await import('./tokens.js');
|
||||||
|
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
|
||||||
|
app = Fastify();
|
||||||
|
await app.register(routesModule.tokensRoutes);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db.delete(schema.routeGroupSources).run();
|
||||||
|
await db.delete(schema.routeChannels).run();
|
||||||
|
await db.delete(schema.tokenRoutes).run();
|
||||||
|
await db.delete(schema.accountTokens).run();
|
||||||
|
await db.delete(schema.accounts).run();
|
||||||
|
await db.delete(schema.sites).run();
|
||||||
|
seedId = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears cooldown and failure counters for a direct route', async () => {
|
||||||
|
const seeded = await seedAccountWithToken();
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-4o-mini',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const channel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: seeded.account.id,
|
||||||
|
tokenId: seeded.token.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
failCount: 8,
|
||||||
|
lastFailAt: '2026-04-01T00:00:00.000Z',
|
||||||
|
consecutiveFailCount: 2,
|
||||||
|
cooldownLevel: 3,
|
||||||
|
cooldownUntil: '2099-01-01T00:00:00.000Z',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/routes/${route.id}/cooldown/clear`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
clearedChannels: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshed = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, channel.id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
expect(refreshed).toMatchObject({
|
||||||
|
failCount: 0,
|
||||||
|
lastFailAt: null,
|
||||||
|
consecutiveFailCount: 0,
|
||||||
|
cooldownLevel: 0,
|
||||||
|
cooldownUntil: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears cooldown for source-route channels exposed by explicit groups', async () => {
|
||||||
|
const seeded = await seedAccountWithToken();
|
||||||
|
const sourceRoute = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'claude-sonnet-4-5',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const groupRoute = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'claude-opus-4-6',
|
||||||
|
displayName: 'claude-opus-4-6',
|
||||||
|
routeMode: 'explicit_group',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.routeGroupSources).values({
|
||||||
|
groupRouteId: groupRoute.id,
|
||||||
|
sourceRouteId: sourceRoute.id,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const channel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: sourceRoute.id,
|
||||||
|
accountId: seeded.account.id,
|
||||||
|
tokenId: seeded.token.id,
|
||||||
|
sourceModel: 'claude-sonnet-4-5',
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
failCount: 5,
|
||||||
|
lastFailAt: '2026-04-01T00:00:00.000Z',
|
||||||
|
consecutiveFailCount: 1,
|
||||||
|
cooldownLevel: 2,
|
||||||
|
cooldownUntil: '2099-01-01T00:00:00.000Z',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/routes/${groupRoute.id}/cooldown/clear`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
clearedChannels: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshed = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, channel.id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
expect(refreshed).toMatchObject({
|
||||||
|
failCount: 0,
|
||||||
|
lastFailAt: null,
|
||||||
|
consecutiveFailCount: 0,
|
||||||
|
cooldownLevel: 0,
|
||||||
|
cooldownUntil: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only clears cooldown for explicit-group source routes that are enabled exact routes', async () => {
|
||||||
|
const seeded = await seedAccountWithToken();
|
||||||
|
const visibleSourceRoute = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-5.4',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
const disabledSourceRoute = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-4.1',
|
||||||
|
enabled: false,
|
||||||
|
}).returning().get();
|
||||||
|
const wildcardSourceRoute = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-*',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const groupRoute = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-clear-group',
|
||||||
|
displayName: 'gpt-clear-group',
|
||||||
|
routeMode: 'explicit_group',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.routeGroupSources).values([
|
||||||
|
{ groupRouteId: groupRoute.id, sourceRouteId: visibleSourceRoute.id },
|
||||||
|
{ groupRouteId: groupRoute.id, sourceRouteId: disabledSourceRoute.id },
|
||||||
|
{ groupRouteId: groupRoute.id, sourceRouteId: wildcardSourceRoute.id },
|
||||||
|
]).run();
|
||||||
|
|
||||||
|
const visibleChannel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: visibleSourceRoute.id,
|
||||||
|
accountId: seeded.account.id,
|
||||||
|
tokenId: seeded.token.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
failCount: 4,
|
||||||
|
lastFailAt: '2026-04-01T00:00:00.000Z',
|
||||||
|
consecutiveFailCount: 1,
|
||||||
|
cooldownLevel: 2,
|
||||||
|
cooldownUntil: '2099-01-01T00:00:00.000Z',
|
||||||
|
}).returning().get();
|
||||||
|
const disabledChannel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: disabledSourceRoute.id,
|
||||||
|
accountId: seeded.account.id,
|
||||||
|
tokenId: seeded.token.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
failCount: 6,
|
||||||
|
lastFailAt: '2026-04-01T00:00:00.000Z',
|
||||||
|
consecutiveFailCount: 2,
|
||||||
|
cooldownLevel: 3,
|
||||||
|
cooldownUntil: '2099-01-01T00:00:00.000Z',
|
||||||
|
}).returning().get();
|
||||||
|
const wildcardChannel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: wildcardSourceRoute.id,
|
||||||
|
accountId: seeded.account.id,
|
||||||
|
tokenId: seeded.token.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
failCount: 7,
|
||||||
|
lastFailAt: '2026-04-01T00:00:00.000Z',
|
||||||
|
consecutiveFailCount: 2,
|
||||||
|
cooldownLevel: 3,
|
||||||
|
cooldownUntil: '2099-01-01T00:00:00.000Z',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/routes/${groupRoute.id}/cooldown/clear`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
clearedChannels: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshedVisible = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, visibleChannel.id))
|
||||||
|
.get();
|
||||||
|
const refreshedDisabled = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, disabledChannel.id))
|
||||||
|
.get();
|
||||||
|
const refreshedWildcard = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, wildcardChannel.id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
expect(refreshedVisible).toMatchObject({
|
||||||
|
failCount: 0,
|
||||||
|
lastFailAt: null,
|
||||||
|
consecutiveFailCount: 0,
|
||||||
|
cooldownLevel: 0,
|
||||||
|
cooldownUntil: null,
|
||||||
|
});
|
||||||
|
expect(refreshedDisabled?.cooldownUntil).toBe('2099-01-01T00:00:00.000Z');
|
||||||
|
expect(refreshedWildcard?.cooldownUntil).toBe('2099-01-01T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
parseRouteDecisionSnapshot,
|
parseRouteDecisionSnapshot,
|
||||||
saveRouteDecisionSnapshots,
|
saveRouteDecisionSnapshots,
|
||||||
} from '../../services/routeDecisionSnapshotStore.js';
|
} from '../../services/routeDecisionSnapshotStore.js';
|
||||||
|
import { clearRouteCooldown } from '../../services/routeCooldownService.js';
|
||||||
import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js';
|
import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js';
|
||||||
import {
|
import {
|
||||||
parseRouteChannelBatchCreatePayload,
|
parseRouteChannelBatchCreatePayload,
|
||||||
@@ -768,8 +769,17 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
const channelsByRoute = await fetchChannelsForRouteRows([route]);
|
const channelsByRoute = await fetchChannelsForRouteRows([route]);
|
||||||
return channelsByRoute.get(routeId) || [];
|
return channelsByRoute.get(routeId) || [];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Batch add channels to a route
|
app.post<{ Params: { id: string } }>('/api/routes/:id/cooldown/clear', async (request, reply) => {
|
||||||
|
const routeId = parseInt(request.params.id, 10);
|
||||||
|
const result = await clearRouteCooldown(routeId);
|
||||||
|
if (!result) {
|
||||||
|
return reply.code(404).send({ success: false, message: '路由不存在' });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch add channels to a route
|
||||||
app.post<{ Params: { id: string }; Body: unknown }>('/api/routes/:id/channels/batch', async (request, reply) => {
|
app.post<{ Params: { id: string }; Body: unknown }>('/api/routes/:id/channels/batch', async (request, reply) => {
|
||||||
const parsedBody = parseRouteChannelBatchCreatePayload(request.body);
|
const parsedBody = parseRouteChannelBatchCreatePayload(request.body);
|
||||||
if (!parsedBody.success) {
|
if (!parsedBody.success) {
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
|
import { db, schema } from '../db/index.js';
|
||||||
|
import {
|
||||||
|
clearRouteDecisionSnapshot,
|
||||||
|
clearRouteDecisionSnapshots,
|
||||||
|
} from './routeDecisionSnapshotStore.js';
|
||||||
|
import { tokenRouter } from './tokenRouter.js';
|
||||||
|
import { normalizeTokenRouteMode, type RouteMode } from '../../shared/tokenRouteContract.js';
|
||||||
|
|
||||||
|
type RouteRow = typeof schema.tokenRoutes.$inferSelect & {
|
||||||
|
routeMode: RouteMode;
|
||||||
|
sourceRouteIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function isExactModelPattern(modelPattern: string): boolean {
|
||||||
|
const normalized = modelPattern.trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
if (normalized.toLowerCase().startsWith('re:')) return false;
|
||||||
|
return !/[\*\?]/.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExplicitGroupRoute(route: Pick<RouteRow, 'routeMode'> | Pick<typeof schema.tokenRoutes.$inferSelect, 'routeMode'>): boolean {
|
||||||
|
return normalizeTokenRouteMode(route.routeMode) === 'explicit_group';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSourceRouteIds(sourceRouteIds: number[]): number[] {
|
||||||
|
return Array.from(new Set(
|
||||||
|
sourceRouteIds
|
||||||
|
.filter((routeId): routeId is number => Number.isFinite(routeId) && routeId > 0)
|
||||||
|
.map((routeId) => Math.trunc(routeId)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRouteSourceIdsMap(routeIds: number[]): Promise<Map<number, number[]>> {
|
||||||
|
const normalizedRouteIds = normalizeSourceRouteIds(routeIds);
|
||||||
|
if (normalizedRouteIds.length === 0) return new Map();
|
||||||
|
|
||||||
|
const rows = await db.select().from(schema.routeGroupSources)
|
||||||
|
.where(inArray(schema.routeGroupSources.groupRouteId, normalizedRouteIds))
|
||||||
|
.all();
|
||||||
|
const sourceRouteIdsByRouteId = new Map<number, number[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!sourceRouteIdsByRouteId.has(row.groupRouteId)) {
|
||||||
|
sourceRouteIdsByRouteId.set(row.groupRouteId, []);
|
||||||
|
}
|
||||||
|
sourceRouteIdsByRouteId.get(row.groupRouteId)!.push(row.sourceRouteId);
|
||||||
|
}
|
||||||
|
for (const [routeId, sourceRouteIds] of sourceRouteIdsByRouteId.entries()) {
|
||||||
|
sourceRouteIdsByRouteId.set(routeId, normalizeSourceRouteIds(sourceRouteIds));
|
||||||
|
}
|
||||||
|
return sourceRouteIdsByRouteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decorateRoutesWithSources(
|
||||||
|
routes: Array<typeof schema.tokenRoutes.$inferSelect>,
|
||||||
|
sourceRouteIdsByRouteId: Map<number, number[]>,
|
||||||
|
): RouteRow[] {
|
||||||
|
return routes.map((route) => ({
|
||||||
|
...route,
|
||||||
|
routeMode: normalizeTokenRouteMode(route.routeMode),
|
||||||
|
sourceRouteIds: sourceRouteIdsByRouteId.get(route.id) ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRouteWithSources(routeId: number): Promise<RouteRow | null> {
|
||||||
|
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
|
||||||
|
if (!route) return null;
|
||||||
|
const sourceRouteIdsByRouteId = await loadRouteSourceIdsMap([routeId]);
|
||||||
|
return decorateRoutesWithSources([route], sourceRouteIdsByRouteId)[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCooldownClearRouteIds(route: RouteRow): Promise<number[]> {
|
||||||
|
if (!isExplicitGroupRoute(route)) {
|
||||||
|
return [route.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceRouteIds = normalizeSourceRouteIds(route.sourceRouteIds);
|
||||||
|
if (sourceRouteIds.length === 0) return [];
|
||||||
|
|
||||||
|
const sourceRoutes = await db.select({
|
||||||
|
id: schema.tokenRoutes.id,
|
||||||
|
modelPattern: schema.tokenRoutes.modelPattern,
|
||||||
|
routeMode: schema.tokenRoutes.routeMode,
|
||||||
|
enabled: schema.tokenRoutes.enabled,
|
||||||
|
}).from(schema.tokenRoutes)
|
||||||
|
.where(inArray(schema.tokenRoutes.id, sourceRouteIds))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return sourceRoutes
|
||||||
|
.filter((sourceRoute) => (
|
||||||
|
sourceRoute.enabled
|
||||||
|
&& normalizeTokenRouteMode(sourceRoute.routeMode) !== 'explicit_group'
|
||||||
|
&& isExactModelPattern(sourceRoute.modelPattern)
|
||||||
|
))
|
||||||
|
.map((sourceRoute) => sourceRoute.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearDependentExplicitGroupSnapshotsBySourceRouteIds(sourceRouteIds: number[]): Promise<void> {
|
||||||
|
const normalizedSourceRouteIds = normalizeSourceRouteIds(sourceRouteIds);
|
||||||
|
if (normalizedSourceRouteIds.length === 0) return;
|
||||||
|
|
||||||
|
const rows = await db.select({ groupRouteId: schema.routeGroupSources.groupRouteId })
|
||||||
|
.from(schema.routeGroupSources)
|
||||||
|
.where(inArray(schema.routeGroupSources.sourceRouteId, normalizedSourceRouteIds))
|
||||||
|
.all();
|
||||||
|
const dependentRouteIds: number[] = Array.from(new Set(
|
||||||
|
rows
|
||||||
|
.map((row) => row.groupRouteId)
|
||||||
|
.filter((routeId): routeId is number => Number.isFinite(routeId) && routeId > 0),
|
||||||
|
));
|
||||||
|
if (dependentRouteIds.length === 0) return;
|
||||||
|
await clearRouteDecisionSnapshots(dependentRouteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearRouteCooldown(routeId: number): Promise<{ success: true; clearedChannels: number } | null> {
|
||||||
|
const route = await getRouteWithSources(routeId);
|
||||||
|
if (!route) return null;
|
||||||
|
|
||||||
|
const actualRouteIds = await resolveCooldownClearRouteIds(route);
|
||||||
|
const channelRows: Array<{ id: number; routeId: number }> = actualRouteIds.length > 0
|
||||||
|
? await db.select({
|
||||||
|
id: schema.routeChannels.id,
|
||||||
|
routeId: schema.routeChannels.routeId,
|
||||||
|
}).from(schema.routeChannels)
|
||||||
|
.where(inArray(schema.routeChannels.routeId, actualRouteIds))
|
||||||
|
.all()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const affectedRouteIds = Array.from(new Set(channelRows.map((row) => row.routeId)));
|
||||||
|
const clearedChannels = await tokenRouter.clearChannelFailureState(channelRows.map((row) => row.id));
|
||||||
|
|
||||||
|
await clearRouteDecisionSnapshot(route.id);
|
||||||
|
if (affectedRouteIds.length > 0) {
|
||||||
|
await clearRouteDecisionSnapshots(affectedRouteIds);
|
||||||
|
await clearDependentExplicitGroupSnapshotsBySourceRouteIds(affectedRouteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
clearedChannels,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,10 +13,12 @@ describe('TokenRouter runtime cache', () => {
|
|||||||
let schema: DbModule['schema'];
|
let schema: DbModule['schema'];
|
||||||
let TokenRouter: TokenRouterModule['TokenRouter'];
|
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||||
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||||
|
let isChannelRecentlyFailed: TokenRouterModule['isChannelRecentlyFailed'];
|
||||||
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
|
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
|
||||||
let config: ConfigModule['config'];
|
let config: ConfigModule['config'];
|
||||||
let dataDir = '';
|
let dataDir = '';
|
||||||
let originalCacheTtlMs = 0;
|
let originalCacheTtlMs = 0;
|
||||||
|
let originalFailureCooldownMaxSec = 0;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-token-router-cache-'));
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-token-router-cache-'));
|
||||||
@@ -30,9 +32,11 @@ describe('TokenRouter runtime cache', () => {
|
|||||||
schema = dbModule.schema;
|
schema = dbModule.schema;
|
||||||
TokenRouter = tokenRouterModule.TokenRouter;
|
TokenRouter = tokenRouterModule.TokenRouter;
|
||||||
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||||
|
isChannelRecentlyFailed = tokenRouterModule.isChannelRecentlyFailed;
|
||||||
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
|
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
|
||||||
config = configModule.config;
|
config = configModule.config;
|
||||||
originalCacheTtlMs = config.tokenRouterCacheTtlMs;
|
originalCacheTtlMs = config.tokenRouterCacheTtlMs;
|
||||||
|
originalFailureCooldownMaxSec = config.tokenRouterFailureCooldownMaxSec;
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -43,12 +47,14 @@ describe('TokenRouter runtime cache', () => {
|
|||||||
await db.delete(schema.accounts).run();
|
await db.delete(schema.accounts).run();
|
||||||
await db.delete(schema.sites).run();
|
await db.delete(schema.sites).run();
|
||||||
config.tokenRouterCacheTtlMs = 60_000;
|
config.tokenRouterCacheTtlMs = 60_000;
|
||||||
|
config.tokenRouterFailureCooldownMaxSec = originalFailureCooldownMaxSec;
|
||||||
invalidateTokenRouterCache();
|
invalidateTokenRouterCache();
|
||||||
resetSiteRuntimeHealthState();
|
resetSiteRuntimeHealthState();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
config.tokenRouterCacheTtlMs = originalCacheTtlMs;
|
config.tokenRouterCacheTtlMs = originalCacheTtlMs;
|
||||||
|
config.tokenRouterFailureCooldownMaxSec = originalFailureCooldownMaxSec;
|
||||||
invalidateTokenRouterCache();
|
invalidateTokenRouterCache();
|
||||||
resetSiteRuntimeHealthState();
|
resetSiteRuntimeHealthState();
|
||||||
delete process.env.DATA_DIR;
|
delete process.env.DATA_DIR;
|
||||||
@@ -175,6 +181,73 @@ describe('TokenRouter runtime cache', () => {
|
|||||||
expect(thirdCooldownMs).toBeLessThanOrEqual(35_000);
|
expect(thirdCooldownMs).toBeLessThanOrEqual(35_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('caps generic failure cooldowns at the configured maximum', async () => {
|
||||||
|
config.tokenRouterFailureCooldownMaxSec = 20;
|
||||||
|
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'capped-cooldown-site',
|
||||||
|
url: 'https://capped-cooldown.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'capped-cooldown-user',
|
||||||
|
accessToken: 'capped-cooldown-access-token',
|
||||||
|
apiToken: 'capped-cooldown-api-token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const token = await db.insert(schema.accountTokens).values({
|
||||||
|
accountId: account.id,
|
||||||
|
name: 'capped-cooldown-token',
|
||||||
|
token: 'sk-capped-cooldown-token',
|
||||||
|
enabled: true,
|
||||||
|
isDefault: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-4o-mini',
|
||||||
|
routingStrategy: 'weighted',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const channel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: token.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
|
||||||
|
await router.recordFailure(channel.id);
|
||||||
|
await router.recordFailure(channel.id);
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await router.recordFailure(channel.id);
|
||||||
|
|
||||||
|
const record = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, channel.id))
|
||||||
|
.get();
|
||||||
|
const cooldownMs = Date.parse(String(record?.cooldownUntil || '')) - startedAt;
|
||||||
|
|
||||||
|
expect(cooldownMs).toBeGreaterThanOrEqual(17_000);
|
||||||
|
expect(cooldownMs).toBeLessThanOrEqual(23_000);
|
||||||
|
const recentFailureCheckAt = Date.now();
|
||||||
|
expect(isChannelRecentlyFailed({
|
||||||
|
failCount: 3,
|
||||||
|
lastFailAt: new Date(recentFailureCheckAt - 19_000).toISOString(),
|
||||||
|
}, recentFailureCheckAt)).toBe(true);
|
||||||
|
expect(isChannelRecentlyFailed({
|
||||||
|
failCount: 3,
|
||||||
|
lastFailAt: new Date(recentFailureCheckAt - 21_000).toISOString(),
|
||||||
|
}, recentFailureCheckAt)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses codex oauth reset hints for usage-limit cooldowns', async () => {
|
it('uses codex oauth reset hints for usage-limit cooldowns', async () => {
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'codex-oauth-site',
|
name: 'codex-oauth-site',
|
||||||
|
|||||||
@@ -594,6 +594,88 @@ describe('TokenRouter selection scoring', () => {
|
|||||||
expect((recoveredCandidateB?.probability || 0)).toBeLessThan(70);
|
expect((recoveredCandidateB?.probability || 0)).toBeLessThan(70);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears persisted runtime breaker state when channel cooldown is manually cleared', async () => {
|
||||||
|
config.routingWeights = {
|
||||||
|
baseWeightFactor: 1,
|
||||||
|
valueScoreFactor: 0,
|
||||||
|
costWeight: 0,
|
||||||
|
balanceWeight: 0,
|
||||||
|
usageWeight: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const route = await createRoute('gpt-5.4');
|
||||||
|
|
||||||
|
const siteA = await createSite('clear-breaker-a');
|
||||||
|
const accountA = await createAccount(siteA.id, 'clear-breaker-user-a');
|
||||||
|
const tokenA = await createToken(accountA.id, 'clear-breaker-token-a');
|
||||||
|
const channelA = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: accountA.id,
|
||||||
|
tokenId: tokenA.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const siteB = await createSite('clear-breaker-b');
|
||||||
|
const accountB = await createAccount(siteB.id, 'clear-breaker-user-b');
|
||||||
|
const tokenB = await createToken(accountB.id, 'clear-breaker-token-b');
|
||||||
|
const channelB = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: accountB.id,
|
||||||
|
tokenId: tokenB.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
await router.recordFailure(channelA.id, {
|
||||||
|
status: 502,
|
||||||
|
errorText: 'Bad gateway',
|
||||||
|
modelName: 'gpt-5.4',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.update(schema.routeChannels).set({
|
||||||
|
cooldownUntil: null,
|
||||||
|
lastFailAt: null,
|
||||||
|
failCount: 0,
|
||||||
|
consecutiveFailCount: 0,
|
||||||
|
cooldownLevel: 0,
|
||||||
|
}).where(eq(schema.routeChannels.id, channelA.id)).run();
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
|
||||||
|
let decision = await router.explainSelection('gpt-5.4');
|
||||||
|
const breakerCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
|
||||||
|
const breakerCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
|
||||||
|
expect(breakerCandidateA?.reason || '').toContain('熔断');
|
||||||
|
expect((breakerCandidateA?.probability || 0)).toBe(0);
|
||||||
|
expect((breakerCandidateB?.probability || 0)).toBe(100);
|
||||||
|
|
||||||
|
await router.clearChannelFailureState([channelA.id]);
|
||||||
|
resetSiteRuntimeHealthState();
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
|
||||||
|
const refreshedChannel = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, channelA.id))
|
||||||
|
.get();
|
||||||
|
expect(refreshedChannel).toMatchObject({
|
||||||
|
failCount: 0,
|
||||||
|
lastFailAt: null,
|
||||||
|
consecutiveFailCount: 0,
|
||||||
|
cooldownLevel: 0,
|
||||||
|
cooldownUntil: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
decision = await router.explainSelection('gpt-5.4');
|
||||||
|
const recoveredCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
|
||||||
|
const recoveredCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
|
||||||
|
expect(recoveredCandidateA?.reason || '').not.toContain('熔断');
|
||||||
|
expect((recoveredCandidateA?.probability || 0)).toBeGreaterThan(30);
|
||||||
|
expect((recoveredCandidateB?.probability || 0)).toBeLessThan(70);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not open a site breaker for repeated timeout validation errors', async () => {
|
it('does not open a site breaker for repeated timeout validation errors', async () => {
|
||||||
config.routingWeights = {
|
config.routingWeights = {
|
||||||
baseWeightFactor: 1,
|
baseWeightFactor: 1,
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
import { upsertSetting } from '../db/upsertSetting.js';
|
import { upsertSetting } from '../db/upsertSetting.js';
|
||||||
import { config } from '../config.js';
|
import {
|
||||||
|
config,
|
||||||
|
normalizeTokenRouterFailureCooldownMaxSec,
|
||||||
|
TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC_CEILING,
|
||||||
|
} from '../config.js';
|
||||||
import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js';
|
import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js';
|
||||||
import { RETRYABLE_TIMEOUT_PATTERNS } from './proxyRetryPolicy.js';
|
import { RETRYABLE_TIMEOUT_PATTERNS } from './proxyRetryPolicy.js';
|
||||||
import {
|
import {
|
||||||
@@ -203,6 +207,21 @@ function resolveFailureBackoffSec(failCount?: number | null): number {
|
|||||||
return Math.min(FAILURE_BACKOFF_BASE_SEC * fibonacciNumber(normalizedFailCount), MAX_FAILURE_BACKOFF_SEC);
|
return Math.min(FAILURE_BACKOFF_BASE_SEC * fibonacciNumber(normalizedFailCount), MAX_FAILURE_BACKOFF_SEC);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveConfiguredFailureCooldownMaxMs(): number {
|
||||||
|
const normalized = normalizeTokenRouterFailureCooldownMaxSec(config.tokenRouterFailureCooldownMaxSec)
|
||||||
|
?? TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC_CEILING;
|
||||||
|
return Math.max(1_000, normalized * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampFailureCooldownMs(cooldownMs: number): number {
|
||||||
|
const normalized = Math.max(0, Math.trunc(cooldownMs));
|
||||||
|
return Math.min(normalized, resolveConfiguredFailureCooldownMaxMs());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEffectiveFailureCooldownMs(failCount?: number | null): number {
|
||||||
|
return clampFailureCooldownMs(resolveFailureBackoffSec(failCount) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveRoundRobinCooldownSec(level: number): number {
|
function resolveRoundRobinCooldownSec(level: number): number {
|
||||||
const normalizedLevel = Math.max(0, Math.min(ROUND_ROBIN_COOLDOWN_LEVELS_SEC.length - 1, Math.trunc(level)));
|
const normalizedLevel = Math.max(0, Math.min(ROUND_ROBIN_COOLDOWN_LEVELS_SEC.length - 1, Math.trunc(level)));
|
||||||
return ROUND_ROBIN_COOLDOWN_LEVELS_SEC[normalizedLevel] ?? 0;
|
return ROUND_ROBIN_COOLDOWN_LEVELS_SEC[normalizedLevel] ?? 0;
|
||||||
@@ -695,6 +714,45 @@ export async function flushSiteRuntimeHealthPersistence(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearRuntimeHealthStatesForChannels(rows: Array<{
|
||||||
|
siteId: number;
|
||||||
|
sourceModel: string | null;
|
||||||
|
routeModelPattern: string;
|
||||||
|
}>): boolean {
|
||||||
|
let changed = false;
|
||||||
|
const modelKeysBySiteId = new Map<number, Set<string>>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (siteRuntimeHealthStates.delete(row.siteId)) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedModelName = normalizeChannelSourceModel(row.sourceModel)
|
||||||
|
|| (isExactRouteModelPattern(row.routeModelPattern) ? row.routeModelPattern.trim() : '');
|
||||||
|
const modelKey = normalizeModelAlias(resolvedModelName);
|
||||||
|
if (!modelKey) continue;
|
||||||
|
if (!modelKeysBySiteId.has(row.siteId)) {
|
||||||
|
modelKeysBySiteId.set(row.siteId, new Set());
|
||||||
|
}
|
||||||
|
modelKeysBySiteId.get(row.siteId)!.add(modelKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [siteId, modelKeys] of modelKeysBySiteId.entries()) {
|
||||||
|
const modelStates = siteModelRuntimeHealthStates.get(siteId);
|
||||||
|
if (!modelStates) continue;
|
||||||
|
for (const modelKey of modelKeys) {
|
||||||
|
if (modelStates.delete(modelKey)) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (modelStates.size === 0) {
|
||||||
|
siteModelRuntimeHealthStates.delete(siteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
export function getSiteRuntimeHealthMultiplier(siteId: number, nowMs = Date.now()): number {
|
export function getSiteRuntimeHealthMultiplier(siteId: number, nowMs = Date.now()): number {
|
||||||
const state = siteRuntimeHealthStates.get(siteId);
|
const state = siteRuntimeHealthStates.get(siteId);
|
||||||
return getRuntimeHealthMultiplier(state, nowMs);
|
return getRuntimeHealthMultiplier(state, nowMs);
|
||||||
@@ -919,15 +977,16 @@ export function isChannelRecentlyFailed(
|
|||||||
nowMs = Date.now(),
|
nowMs = Date.now(),
|
||||||
avoidSec = resolveFailureBackoffSec(channel.failCount),
|
avoidSec = resolveFailureBackoffSec(channel.failCount),
|
||||||
): boolean {
|
): boolean {
|
||||||
if (avoidSec <= 0) return false;
|
const avoidMs = clampFailureCooldownMs(avoidSec * 1000);
|
||||||
if ((channel.failCount ?? 0) <= 0) return false;
|
if (avoidMs <= 0) return false;
|
||||||
if (!channel.lastFailAt) return false;
|
if ((channel.failCount ?? 0) <= 0) return false;
|
||||||
|
if (!channel.lastFailAt) return false;
|
||||||
const failTs = Date.parse(channel.lastFailAt);
|
|
||||||
if (Number.isNaN(failTs)) return false;
|
const failTs = Date.parse(channel.lastFailAt);
|
||||||
|
if (Number.isNaN(failTs)) return false;
|
||||||
return nowMs - failTs < avoidSec * 1000;
|
|
||||||
}
|
return nowMs - failTs < avoidMs;
|
||||||
|
}
|
||||||
|
|
||||||
export function filterRecentlyFailedCandidates<T extends { channel: FailureAwareChannel }>(
|
export function filterRecentlyFailedCandidates<T extends { channel: FailureAwareChannel }>(
|
||||||
candidates: T[],
|
candidates: T[],
|
||||||
@@ -1771,6 +1830,44 @@ export class TokenRouter {
|
|||||||
recordSiteRuntimeSuccess(account.siteId, latencyMs, modelName);
|
recordSiteRuntimeSuccess(account.siteId, latencyMs, modelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear persisted failure and cooldown state for the given channels.
|
||||||
|
*/
|
||||||
|
async clearChannelFailureState(channelIds: number[]): Promise<number> {
|
||||||
|
const normalizedChannelIds = Array.from(new Set(
|
||||||
|
channelIds
|
||||||
|
.filter((channelId): channelId is number => Number.isFinite(channelId) && channelId > 0)
|
||||||
|
.map((channelId) => Math.trunc(channelId)),
|
||||||
|
));
|
||||||
|
if (normalizedChannelIds.length === 0) return 0;
|
||||||
|
|
||||||
|
await ensureSiteRuntimeHealthStateLoaded();
|
||||||
|
const runtimeHealthRows = await db.select({
|
||||||
|
siteId: schema.accounts.siteId,
|
||||||
|
sourceModel: schema.routeChannels.sourceModel,
|
||||||
|
routeModelPattern: schema.tokenRoutes.modelPattern,
|
||||||
|
}).from(schema.routeChannels)
|
||||||
|
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
|
||||||
|
.innerJoin(schema.tokenRoutes, eq(schema.routeChannels.routeId, schema.tokenRoutes.id))
|
||||||
|
.where(inArray(schema.routeChannels.id, normalizedChannelIds))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const result = await db.update(schema.routeChannels).set({
|
||||||
|
failCount: 0,
|
||||||
|
lastFailAt: null,
|
||||||
|
consecutiveFailCount: 0,
|
||||||
|
cooldownLevel: 0,
|
||||||
|
cooldownUntil: null,
|
||||||
|
}).where(inArray(schema.routeChannels.id, normalizedChannelIds)).run();
|
||||||
|
|
||||||
|
if (clearRuntimeHealthStatesForChannels(runtimeHealthRows)) {
|
||||||
|
await persistSiteRuntimeHealthState();
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
return Number(result?.changes || normalizedChannelIds.length);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record failure and set cooldown.
|
* Record failure and set cooldown.
|
||||||
*/
|
*/
|
||||||
@@ -1810,12 +1907,13 @@ export class TokenRouter {
|
|||||||
if (consecutiveFailCount >= ROUND_ROBIN_FAILURE_THRESHOLD) {
|
if (consecutiveFailCount >= ROUND_ROBIN_FAILURE_THRESHOLD) {
|
||||||
cooldownLevel = Math.min(cooldownLevel + 1, ROUND_ROBIN_COOLDOWN_LEVELS_SEC.length - 1);
|
cooldownLevel = Math.min(cooldownLevel + 1, ROUND_ROBIN_COOLDOWN_LEVELS_SEC.length - 1);
|
||||||
const cooldownSec = resolveRoundRobinCooldownSec(cooldownLevel);
|
const cooldownSec = resolveRoundRobinCooldownSec(cooldownLevel);
|
||||||
cooldownUntil = cooldownSec > 0 ? new Date(nowMs + cooldownSec * 1000).toISOString() : null;
|
cooldownUntil = cooldownSec > 0
|
||||||
|
? new Date(nowMs + clampFailureCooldownMs(cooldownSec * 1000)).toISOString()
|
||||||
|
: null;
|
||||||
consecutiveFailCount = 0;
|
consecutiveFailCount = 0;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const cooldownSec = resolveFailureBackoffSec(failCount);
|
cooldownUntil = new Date(nowMs + resolveEffectiveFailureCooldownMs(failCount)).toISOString();
|
||||||
cooldownUntil = new Date(nowMs + cooldownSec * 1000).toISOString();
|
|
||||||
consecutiveFailCount = 0;
|
consecutiveFailCount = 0;
|
||||||
cooldownLevel = 0;
|
cooldownLevel = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -386,6 +386,7 @@ export type RuntimeSettingsPayload = {
|
|||||||
notifyCooldownSec?: number;
|
notifyCooldownSec?: number;
|
||||||
adminIpAllowlist?: string[] | string;
|
adminIpAllowlist?: string[] | string;
|
||||||
routingFallbackUnitCost?: number;
|
routingFallbackUnitCost?: number;
|
||||||
|
tokenRouterFailureCooldownMaxSec?: number;
|
||||||
routingWeights?: RuntimeRoutingWeightsPayload;
|
routingWeights?: RuntimeRoutingWeightsPayload;
|
||||||
proxyErrorKeywords?: string[] | string;
|
proxyErrorKeywords?: string[] | string;
|
||||||
proxyEmptyContentFailEnabled?: boolean;
|
proxyEmptyContentFailEnabled?: boolean;
|
||||||
@@ -717,6 +718,7 @@ export const api = {
|
|||||||
addRoute: (data: any) => request('/api/routes', { method: 'POST', body: JSON.stringify(data) }),
|
addRoute: (data: any) => request('/api/routes', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
updateRoute: (id: number, data: any) => request(`/api/routes/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
updateRoute: (id: number, data: any) => request(`/api/routes/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
deleteRoute: (id: number) => request(`/api/routes/${id}`, { method: 'DELETE' }),
|
deleteRoute: (id: number) => request(`/api/routes/${id}`, { method: 'DELETE' }),
|
||||||
|
clearRouteCooldown: (id: number) => request(`/api/routes/${id}/cooldown/clear`, { method: 'POST' }),
|
||||||
batchUpdateRoutes: (data: { ids: number[]; action: 'enable' | 'disable' }) =>
|
batchUpdateRoutes: (data: { ids: number[]; action: 'enable' | 'disable' }) =>
|
||||||
request('/api/routes/batch', { method: 'POST', body: JSON.stringify(data) }),
|
request('/api/routes/batch', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
addChannel: (routeId: number, data: any) => request(`/api/routes/${routeId}/channels`, { method: 'POST', body: JSON.stringify(data) }),
|
addChannel: (routeId: number, data: any) => request(`/api/routes/${routeId}/channels`, { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
|
|||||||
const PROXY_TOKEN_PREFIX = 'sk-';
|
const PROXY_TOKEN_PREFIX = 'sk-';
|
||||||
const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token';
|
const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token';
|
||||||
const FACTORY_RESET_CONFIRM_SECONDS = 3;
|
const FACTORY_RESET_CONFIRM_SECONDS = 3;
|
||||||
|
const SECONDS_PER_DAY = 24 * 60 * 60;
|
||||||
|
const ROUTE_COOLDOWN_UNIT_OPTIONS = [
|
||||||
|
{ value: 'second', label: '秒', multiplierSec: 1 },
|
||||||
|
{ value: 'minute', label: '分钟', multiplierSec: 60 },
|
||||||
|
{ value: 'hour', label: '小时', multiplierSec: 60 * 60 },
|
||||||
|
{ value: 'day', label: '天', multiplierSec: SECONDS_PER_DAY },
|
||||||
|
] as const;
|
||||||
const CHECKIN_SCHEDULE_MODE_OPTIONS = [
|
const CHECKIN_SCHEDULE_MODE_OPTIONS = [
|
||||||
{ value: 'cron', label: 'Cron' },
|
{ value: 'cron', label: 'Cron' },
|
||||||
{ value: 'interval', label: '间隔签到' },
|
{ value: 'interval', label: '间隔签到' },
|
||||||
@@ -40,6 +47,7 @@ const CHECKIN_INTERVAL_OPTIONS = Array.from({ length: 24 }, (_, index) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
type DbDialect = 'sqlite' | 'mysql' | 'postgres';
|
type DbDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||||
|
type RouteCooldownUnit = typeof ROUTE_COOLDOWN_UNIT_OPTIONS[number]['value'];
|
||||||
|
|
||||||
type RuntimeSettings = {
|
type RuntimeSettings = {
|
||||||
checkinCron: string;
|
checkinCron: string;
|
||||||
@@ -55,6 +63,8 @@ type RuntimeSettings = {
|
|||||||
proxySessionChannelConcurrencyLimit: number;
|
proxySessionChannelConcurrencyLimit: number;
|
||||||
proxySessionChannelQueueWaitMs: number;
|
proxySessionChannelQueueWaitMs: number;
|
||||||
routingFallbackUnitCost: number;
|
routingFallbackUnitCost: number;
|
||||||
|
routeFailureCooldownMaxValue: number;
|
||||||
|
routeFailureCooldownMaxUnit: RouteCooldownUnit;
|
||||||
routingWeights: RoutingWeights;
|
routingWeights: RoutingWeights;
|
||||||
systemProxyUrl: string;
|
systemProxyUrl: string;
|
||||||
proxyErrorKeywords: string[];
|
proxyErrorKeywords: string[];
|
||||||
@@ -184,6 +194,35 @@ function inferUrlDialect(connectionString: string): 'mysql' | 'postgres' | null
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRouteCooldownInput(seconds: number | null | undefined): {
|
||||||
|
value: number;
|
||||||
|
unit: RouteCooldownUnit;
|
||||||
|
} {
|
||||||
|
const normalizedSeconds = Number.isFinite(Number(seconds)) && Number(seconds) > 0
|
||||||
|
? Math.max(1, Math.trunc(Number(seconds)))
|
||||||
|
: 30 * SECONDS_PER_DAY;
|
||||||
|
|
||||||
|
for (const option of [...ROUTE_COOLDOWN_UNIT_OPTIONS].reverse()) {
|
||||||
|
if (normalizedSeconds % option.multiplierSec === 0) {
|
||||||
|
return {
|
||||||
|
value: normalizedSeconds / option.multiplierSec,
|
||||||
|
unit: option.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: normalizedSeconds,
|
||||||
|
unit: 'second',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRouteCooldownSeconds(value: number, unit: RouteCooldownUnit): number {
|
||||||
|
const normalizedValue = Number.isFinite(value) && value > 0 ? Math.max(1, Math.trunc(value)) : 1;
|
||||||
|
const unitConfig = ROUTE_COOLDOWN_UNIT_OPTIONS.find((option) => option.value === unit) || ROUTE_COOLDOWN_UNIT_OPTIONS[0];
|
||||||
|
return normalizedValue * unitConfig.multiplierSec;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -201,6 +240,8 @@ export default function Settings() {
|
|||||||
proxySessionChannelConcurrencyLimit: 2,
|
proxySessionChannelConcurrencyLimit: 2,
|
||||||
proxySessionChannelQueueWaitMs: 1500,
|
proxySessionChannelQueueWaitMs: 1500,
|
||||||
routingFallbackUnitCost: 1,
|
routingFallbackUnitCost: 1,
|
||||||
|
routeFailureCooldownMaxValue: 30,
|
||||||
|
routeFailureCooldownMaxUnit: 'day',
|
||||||
routingWeights: defaultWeights,
|
routingWeights: defaultWeights,
|
||||||
systemProxyUrl: '',
|
systemProxyUrl: '',
|
||||||
proxyErrorKeywords: [],
|
proxyErrorKeywords: [],
|
||||||
@@ -414,6 +455,7 @@ export default function Settings() {
|
|||||||
api.getRuntimeDatabaseConfig(),
|
api.getRuntimeDatabaseConfig(),
|
||||||
]);
|
]);
|
||||||
setMaskedToken(authInfo.masked || '****');
|
setMaskedToken(authInfo.masked || '****');
|
||||||
|
const routeCooldownInput = resolveRouteCooldownInput(runtimeInfo.tokenRouterFailureCooldownMaxSec);
|
||||||
setRuntime({
|
setRuntime({
|
||||||
checkinCron: runtimeInfo.checkinCron || '0 8 * * *',
|
checkinCron: runtimeInfo.checkinCron || '0 8 * * *',
|
||||||
checkinScheduleMode: runtimeInfo.checkinScheduleMode === 'interval' ? 'interval' : 'cron',
|
checkinScheduleMode: runtimeInfo.checkinScheduleMode === 'interval' ? 'interval' : 'cron',
|
||||||
@@ -438,6 +480,8 @@ export default function Settings() {
|
|||||||
routingFallbackUnitCost: Number(runtimeInfo.routingFallbackUnitCost) > 0
|
routingFallbackUnitCost: Number(runtimeInfo.routingFallbackUnitCost) > 0
|
||||||
? Number(runtimeInfo.routingFallbackUnitCost)
|
? Number(runtimeInfo.routingFallbackUnitCost)
|
||||||
: 1,
|
: 1,
|
||||||
|
routeFailureCooldownMaxValue: routeCooldownInput.value,
|
||||||
|
routeFailureCooldownMaxUnit: routeCooldownInput.unit,
|
||||||
routingWeights: {
|
routingWeights: {
|
||||||
...defaultWeights,
|
...defaultWeights,
|
||||||
...(runtimeInfo.routingWeights || {}),
|
...(runtimeInfo.routingWeights || {}),
|
||||||
@@ -845,6 +889,10 @@ export default function Settings() {
|
|||||||
await api.updateRuntimeSettings({
|
await api.updateRuntimeSettings({
|
||||||
routingWeights: runtime.routingWeights,
|
routingWeights: runtime.routingWeights,
|
||||||
routingFallbackUnitCost: runtime.routingFallbackUnitCost,
|
routingFallbackUnitCost: runtime.routingFallbackUnitCost,
|
||||||
|
tokenRouterFailureCooldownMaxSec: toRouteCooldownSeconds(
|
||||||
|
runtime.routeFailureCooldownMaxValue,
|
||||||
|
runtime.routeFailureCooldownMaxUnit,
|
||||||
|
),
|
||||||
disableCrossProtocolFallback: runtime.disableCrossProtocolFallback,
|
disableCrossProtocolFallback: runtime.disableCrossProtocolFallback,
|
||||||
});
|
});
|
||||||
toast.success('Routing weights saved');
|
toast.success('Routing weights saved');
|
||||||
@@ -1498,6 +1546,50 @@ export default function Settings() {
|
|||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginBottom: 12, maxWidth: 420 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||||
|
普通失败冷却上限
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'stretch', flexWrap: 'wrap' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
aria-label="路由失败冷却上限数值"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={runtime.routeFailureCooldownMaxValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextValue = Number(e.target.value);
|
||||||
|
setRuntime((prev) => ({
|
||||||
|
...prev,
|
||||||
|
routeFailureCooldownMaxValue: Number.isFinite(nextValue) && nextValue > 0
|
||||||
|
? Math.max(1, Math.trunc(nextValue))
|
||||||
|
: prev.routeFailureCooldownMaxValue,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
style={{ ...inputStyle, flex: '1 1 180px', marginBottom: 0 }}
|
||||||
|
/>
|
||||||
|
<div style={{ width: 132, minWidth: 132 }}>
|
||||||
|
<ModernSelect
|
||||||
|
size="sm"
|
||||||
|
value={runtime.routeFailureCooldownMaxUnit}
|
||||||
|
onChange={(nextValue) => {
|
||||||
|
setRuntime((prev) => ({
|
||||||
|
...prev,
|
||||||
|
routeFailureCooldownMaxUnit: nextValue as RouteCooldownUnit,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
options={ROUTE_COOLDOWN_UNIT_OPTIONS.map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
}))}
|
||||||
|
placeholder="选择单位"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6, lineHeight: 1.6 }}>
|
||||||
|
支持秒、分钟、小时、天。只封顶普通失败与轮询分级冷却;429 限额类冷却仍优先遵循上游 reset 提示,避免过早重试。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => applyRoutingPreset('balanced')}
|
onClick={() => applyRoutingPreset('balanced')}
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export default function TokenRoutes() {
|
|||||||
const [updatingChannel, setUpdatingChannel] = useState<Record<number, boolean>>({});
|
const [updatingChannel, setUpdatingChannel] = useState<Record<number, boolean>>({});
|
||||||
const [savingPriorityByRoute, setSavingPriorityByRoute] = useState<Record<number, boolean>>({});
|
const [savingPriorityByRoute, setSavingPriorityByRoute] = useState<Record<number, boolean>>({});
|
||||||
const [updatingRoutingStrategyByRoute, setUpdatingRoutingStrategyByRoute] = useState<Record<number, boolean>>({});
|
const [updatingRoutingStrategyByRoute, setUpdatingRoutingStrategyByRoute] = useState<Record<number, boolean>>({});
|
||||||
|
const [clearingCooldownByRoute, setClearingCooldownByRoute] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
const [decisionByRoute, setDecisionByRoute] = useState<Record<number, RouteDecision | null>>({});
|
const [decisionByRoute, setDecisionByRoute] = useState<Record<number, RouteDecision | null>>({});
|
||||||
const [loadingDecision, setLoadingDecision] = useState(false);
|
const [loadingDecision, setLoadingDecision] = useState(false);
|
||||||
@@ -1188,6 +1189,41 @@ export default function TokenRoutes() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClearRouteCooldown = async (routeId: number) => {
|
||||||
|
if (clearingCooldownByRoute[routeId]) return;
|
||||||
|
setClearingCooldownByRoute((prev) => ({ ...prev, [routeId]: true }));
|
||||||
|
try {
|
||||||
|
await api.clearRouteCooldown(routeId);
|
||||||
|
toast.success('路由冷却已清除');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadChannels(routeId, true);
|
||||||
|
const route = routeSummaries.find((item) => item.id === routeId);
|
||||||
|
if (route) {
|
||||||
|
if (isRouteExactModel(route)) {
|
||||||
|
const res = await api.getRouteDecision(route.modelPattern);
|
||||||
|
setDecisionByRoute((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[routeId]: (res?.decision || null) as RouteDecision | null,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const res = await api.getRouteWideDecisionsBatch([routeId]);
|
||||||
|
setDecisionByRoute((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[routeId]: (res?.decisions?.[String(routeId)] || null) as RouteDecision | null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('已清除,但刷新失败');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || '清除路由冷却失败');
|
||||||
|
} finally {
|
||||||
|
setClearingCooldownByRoute((prev) => ({ ...prev, [routeId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleExpand = async (routeId: number) => {
|
const toggleExpand = async (routeId: number) => {
|
||||||
const isCurrentlyExpanded = expandedRouteIds.includes(routeId);
|
const isCurrentlyExpanded = expandedRouteIds.includes(routeId);
|
||||||
if (isCurrentlyExpanded) {
|
if (isCurrentlyExpanded) {
|
||||||
@@ -1348,6 +1384,12 @@ export default function TokenRoutes() {
|
|||||||
(channelId: number, routeId: number) => handleSiteBlockModelRef.current(channelId, routeId),
|
(channelId: number, routeId: number) => handleSiteBlockModelRef.current(channelId, routeId),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
const handleClearRouteCooldownRef = useRef(handleClearRouteCooldown);
|
||||||
|
handleClearRouteCooldownRef.current = handleClearRouteCooldown;
|
||||||
|
const stableClearRouteCooldown = useCallback(
|
||||||
|
(routeId: number) => handleClearRouteCooldownRef.current(routeId),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const addChannelModalRoute = addChannelModalRouteId
|
const addChannelModalRoute = addChannelModalRouteId
|
||||||
? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null
|
? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null
|
||||||
@@ -1701,6 +1743,8 @@ export default function TokenRoutes() {
|
|||||||
onEdit={stableEditRoute}
|
onEdit={stableEditRoute}
|
||||||
onDelete={stableDeleteRoute}
|
onDelete={stableDeleteRoute}
|
||||||
onToggleEnabled={stableToggleEnabled}
|
onToggleEnabled={stableToggleEnabled}
|
||||||
|
onClearCooldown={stableClearRouteCooldown}
|
||||||
|
clearingCooldown={!!clearingCooldownByRoute[route.id]}
|
||||||
onRoutingStrategyChange={stableRoutingStrategyChange}
|
onRoutingStrategyChange={stableRoutingStrategyChange}
|
||||||
updatingRoutingStrategy={!!updatingRoutingStrategyByRoute[route.id]}
|
updatingRoutingStrategy={!!updatingRoutingStrategyByRoute[route.id]}
|
||||||
channels={channelsByRouteId[route.id]}
|
channels={channelsByRouteId[route.id]}
|
||||||
@@ -1740,6 +1784,8 @@ export default function TokenRoutes() {
|
|||||||
onEdit={stableEditRoute}
|
onEdit={stableEditRoute}
|
||||||
onDelete={stableDeleteRoute}
|
onDelete={stableDeleteRoute}
|
||||||
onToggleEnabled={stableToggleEnabled}
|
onToggleEnabled={stableToggleEnabled}
|
||||||
|
onClearCooldown={stableClearRouteCooldown}
|
||||||
|
clearingCooldown={!!clearingCooldownByRoute[route.id]}
|
||||||
onRoutingStrategyChange={stableRoutingStrategyChange}
|
onRoutingStrategyChange={stableRoutingStrategyChange}
|
||||||
updatingRoutingStrategy={!!updatingRoutingStrategyByRoute[route.id]}
|
updatingRoutingStrategy={!!updatingRoutingStrategyByRoute[route.id]}
|
||||||
channels={channelsByRouteId[route.id]}
|
channels={channelsByRouteId[route.id]}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ToastProvider } from '../components/Toast.js';
|
||||||
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
|
import Settings from './Settings.js';
|
||||||
|
|
||||||
|
const { apiMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: {
|
||||||
|
getAuthInfo: vi.fn(),
|
||||||
|
getRuntimeSettings: vi.fn(),
|
||||||
|
getDownstreamApiKeys: vi.fn(),
|
||||||
|
getRoutesLite: vi.fn(),
|
||||||
|
getRuntimeDatabaseConfig: vi.fn(),
|
||||||
|
getBrandList: vi.fn(),
|
||||||
|
updateRuntimeSettings: vi.fn(),
|
||||||
|
getModelTokenCandidates: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api.js', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/BrandIcon.js', () => ({
|
||||||
|
BrandGlyph: () => null,
|
||||||
|
InlineBrandIcon: () => null,
|
||||||
|
getBrand: () => null,
|
||||||
|
normalizeBrandIconKey: (icon: string) => icon,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function collectText(node: ReactTestInstance): string {
|
||||||
|
return (node.children || []).map((child) => {
|
||||||
|
if (typeof child === 'string') return child;
|
||||||
|
return collectText(child);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Settings route cooldown cap', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
|
||||||
|
apiMock.getRuntimeSettings.mockResolvedValue({
|
||||||
|
checkinCron: '0 8 * * *',
|
||||||
|
checkinScheduleMode: 'interval',
|
||||||
|
checkinIntervalHours: 6,
|
||||||
|
balanceRefreshCron: '0 * * * *',
|
||||||
|
logCleanupCron: '15 4 * * *',
|
||||||
|
logCleanupUsageLogsEnabled: true,
|
||||||
|
logCleanupProgramLogsEnabled: true,
|
||||||
|
logCleanupRetentionDays: 14,
|
||||||
|
routingFallbackUnitCost: 1,
|
||||||
|
routingWeights: {},
|
||||||
|
tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60,
|
||||||
|
adminIpAllowlist: [],
|
||||||
|
systemProxyUrl: '',
|
||||||
|
});
|
||||||
|
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||||
|
apiMock.getRoutesLite.mockResolvedValue([]);
|
||||||
|
apiMock.getBrandList.mockResolvedValue({ brands: [] });
|
||||||
|
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||||
|
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||||
|
saved: null,
|
||||||
|
restartRequired: false,
|
||||||
|
});
|
||||||
|
apiMock.updateRuntimeSettings.mockResolvedValue({ success: true });
|
||||||
|
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts the selected route cooldown unit back into seconds when saving routing settings', async () => {
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<Settings />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const cooldownInput = root.root.find((node) => (
|
||||||
|
node.type === 'input'
|
||||||
|
&& node.props.type === 'number'
|
||||||
|
&& node.props['aria-label'] === '路由失败冷却上限数值'
|
||||||
|
));
|
||||||
|
const cooldownUnitSelect = root.root.find((node) => (
|
||||||
|
node.type === ModernSelect
|
||||||
|
&& node.props.placeholder === '选择单位'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
cooldownInput.props.onChange({ target: { value: '10' } });
|
||||||
|
cooldownUnitSelect.props.onChange('second');
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).trim() === '保存路由策略'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
saveButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
|
||||||
|
routingWeights: {
|
||||||
|
baseWeightFactor: 0.5,
|
||||||
|
valueScoreFactor: 0.5,
|
||||||
|
costWeight: 0.4,
|
||||||
|
balanceWeight: 0.3,
|
||||||
|
usageWeight: 0.3,
|
||||||
|
},
|
||||||
|
routingFallbackUnitCost: 1,
|
||||||
|
tokenRouterFailureCooldownMaxSec: 10,
|
||||||
|
disableCrossProtocolFallback: false,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('infers seconds as the editing unit when the saved cap is not an even day/hour/minute multiple', async () => {
|
||||||
|
apiMock.getRuntimeSettings.mockResolvedValueOnce({
|
||||||
|
checkinCron: '0 8 * * *',
|
||||||
|
checkinScheduleMode: 'interval',
|
||||||
|
checkinIntervalHours: 6,
|
||||||
|
balanceRefreshCron: '0 * * * *',
|
||||||
|
logCleanupCron: '15 4 * * *',
|
||||||
|
logCleanupUsageLogsEnabled: true,
|
||||||
|
logCleanupProgramLogsEnabled: true,
|
||||||
|
logCleanupRetentionDays: 14,
|
||||||
|
routingFallbackUnitCost: 1,
|
||||||
|
routingWeights: {},
|
||||||
|
tokenRouterFailureCooldownMaxSec: 10,
|
||||||
|
adminIpAllowlist: [],
|
||||||
|
systemProxyUrl: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<Settings />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const cooldownInput = root.root.find((node) => (
|
||||||
|
node.type === 'input'
|
||||||
|
&& node.props.type === 'number'
|
||||||
|
&& node.props['aria-label'] === '路由失败冷却上限数值'
|
||||||
|
));
|
||||||
|
const cooldownUnitSelect = root.root.find((node) => (
|
||||||
|
node.type === ModernSelect
|
||||||
|
&& node.props.placeholder === '选择单位'
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(cooldownInput.props.value).toBe(10);
|
||||||
|
expect(cooldownUnitSelect.props.value).toBe('second');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,8 @@ describe('RouteCard', () => {
|
|||||||
onEdit={vi.fn()}
|
onEdit={vi.fn()}
|
||||||
onDelete={vi.fn()}
|
onDelete={vi.fn()}
|
||||||
onToggleEnabled={vi.fn()}
|
onToggleEnabled={vi.fn()}
|
||||||
|
onClearCooldown={vi.fn()}
|
||||||
|
clearingCooldown={false}
|
||||||
onRoutingStrategyChange={vi.fn()}
|
onRoutingStrategyChange={vi.fn()}
|
||||||
updatingRoutingStrategy={false}
|
updatingRoutingStrategy={false}
|
||||||
channels={undefined}
|
channels={undefined}
|
||||||
@@ -85,4 +87,53 @@ describe('RouteCard', () => {
|
|||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders a clear cooldown action on expanded cards', () => {
|
||||||
|
const onClearCooldown = vi.fn();
|
||||||
|
const root = create(
|
||||||
|
<RouteCard
|
||||||
|
route={buildRoute()}
|
||||||
|
brand={null}
|
||||||
|
expanded
|
||||||
|
onToggleExpand={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
onToggleEnabled={vi.fn()}
|
||||||
|
onClearCooldown={onClearCooldown}
|
||||||
|
clearingCooldown={false}
|
||||||
|
onRoutingStrategyChange={vi.fn()}
|
||||||
|
updatingRoutingStrategy={false}
|
||||||
|
channels={[]}
|
||||||
|
loadingChannels={false}
|
||||||
|
routeDecision={null}
|
||||||
|
loadingDecision={false}
|
||||||
|
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
|
||||||
|
channelTokenDraft={{}}
|
||||||
|
updatingChannel={{}}
|
||||||
|
savingPriority={false}
|
||||||
|
onTokenDraftChange={vi.fn()}
|
||||||
|
onSaveToken={vi.fn()}
|
||||||
|
onDeleteChannel={vi.fn()}
|
||||||
|
onToggleChannelEnabled={vi.fn()}
|
||||||
|
onChannelDragEnd={vi.fn()}
|
||||||
|
onSplitPriorityBucket={vi.fn()}
|
||||||
|
missingTokenSiteItems={[]}
|
||||||
|
missingTokenGroupItems={[]}
|
||||||
|
onCreateTokenForMissing={vi.fn()}
|
||||||
|
onAddChannel={vi.fn()}
|
||||||
|
onSiteBlockModel={vi.fn()}
|
||||||
|
expandedSourceGroupMap={{}}
|
||||||
|
onToggleSourceGroup={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).trim() === '清除冷却'
|
||||||
|
));
|
||||||
|
|
||||||
|
button.props.onClick();
|
||||||
|
expect(onClearCooldown).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ type RouteCardProps = {
|
|||||||
onEdit: (route: RouteSummaryRow) => void;
|
onEdit: (route: RouteSummaryRow) => void;
|
||||||
onDelete: (routeId: number) => void;
|
onDelete: (routeId: number) => void;
|
||||||
onToggleEnabled: (route: RouteSummaryRow) => void;
|
onToggleEnabled: (route: RouteSummaryRow) => void;
|
||||||
|
onClearCooldown: (routeId: number) => void;
|
||||||
|
clearingCooldown: boolean;
|
||||||
onRoutingStrategyChange: (route: RouteSummaryRow, strategy: RouteRoutingStrategy) => void;
|
onRoutingStrategyChange: (route: RouteSummaryRow, strategy: RouteRoutingStrategy) => void;
|
||||||
updatingRoutingStrategy: boolean;
|
updatingRoutingStrategy: boolean;
|
||||||
// Channel data (loaded on demand)
|
// Channel data (loaded on demand)
|
||||||
@@ -106,6 +108,8 @@ function RouteCardInner({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggleEnabled,
|
onToggleEnabled,
|
||||||
|
onClearCooldown,
|
||||||
|
clearingCooldown,
|
||||||
onRoutingStrategyChange,
|
onRoutingStrategyChange,
|
||||||
updatingRoutingStrategy,
|
updatingRoutingStrategy,
|
||||||
channels,
|
channels,
|
||||||
@@ -167,6 +171,14 @@ function RouteCardInner({
|
|||||||
|
|
||||||
const priorityBuckets = buildPriorityBuckets(channels || []);
|
const priorityBuckets = buildPriorityBuckets(channels || []);
|
||||||
const bucketEditorItems = buildPriorityBucketEditorItems(channels || []);
|
const bucketEditorItems = buildPriorityBucketEditorItems(channels || []);
|
||||||
|
const renderClearCooldownButton = () => {
|
||||||
|
if (readOnlyRoute) return null;
|
||||||
|
return (
|
||||||
|
<button onClick={() => onClearCooldown(route.id)} className="btn btn-link btn-link-info" disabled={clearingCooldown}>
|
||||||
|
{clearingCooldown ? tr('清除中...') : tr('清除冷却')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Collapsed card
|
// Collapsed card
|
||||||
if (!expanded) {
|
if (!expanded) {
|
||||||
@@ -325,6 +337,7 @@ function RouteCardInner({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{renderClearCooldownButton()}
|
||||||
{!readOnlyRoute && (explicitGroupRoute || !exactRoute) && (
|
{!readOnlyRoute && (explicitGroupRoute || !exactRoute) && (
|
||||||
<button onClick={() => onEdit(route)} className="btn btn-link">{tr('编辑群组')}</button>
|
<button onClick={() => onEdit(route)} className="btn btn-link">{tr('编辑群组')}</button>
|
||||||
)}
|
)}
|
||||||
@@ -358,6 +371,7 @@ function RouteCardInner({
|
|||||||
</div>
|
</div>
|
||||||
{!readOnlyRoute && (
|
{!readOnlyRoute && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
|
{renderClearCooldownButton()}
|
||||||
{!exactRoute && (
|
{!exactRoute && (
|
||||||
<button onClick={() => onEdit(route)} className="btn btn-link">{explicitGroupRoute ? tr('编辑群组') : tr('编辑路由')}</button>
|
<button onClick={() => onEdit(route)} className="btn btn-link">{explicitGroupRoute ? tr('编辑群组') : tr('编辑路由')}</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user