[codex] Add route cooldown controls (#365)

* Add route cooldown controls

* Address route cooldown review comments
This commit is contained in:
Cita
2026-04-01 16:41:25 +08:00
committed by GitHub
parent 1e0dffe543
commit 532be86e93
16 changed files with 1182 additions and 17 deletions
+2
View File
@@ -386,6 +386,7 @@ export type RuntimeSettingsPayload = {
notifyCooldownSec?: number;
adminIpAllowlist?: string[] | string;
routingFallbackUnitCost?: number;
tokenRouterFailureCooldownMaxSec?: number;
routingWeights?: RuntimeRoutingWeightsPayload;
proxyErrorKeywords?: string[] | string;
proxyEmptyContentFailEnabled?: boolean;
@@ -717,6 +718,7 @@ export const api = {
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) }),
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' }) =>
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) }),
+92
View File
@@ -28,6 +28,13 @@ import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
const PROXY_TOKEN_PREFIX = 'sk-';
const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token';
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 = [
{ value: 'cron', label: 'Cron' },
{ value: 'interval', label: '间隔签到' },
@@ -40,6 +47,7 @@ const CHECKIN_INTERVAL_OPTIONS = Array.from({ length: 24 }, (_, index) => {
};
});
type DbDialect = 'sqlite' | 'mysql' | 'postgres';
type RouteCooldownUnit = typeof ROUTE_COOLDOWN_UNIT_OPTIONS[number]['value'];
type RuntimeSettings = {
checkinCron: string;
@@ -55,6 +63,8 @@ type RuntimeSettings = {
proxySessionChannelConcurrencyLimit: number;
proxySessionChannelQueueWaitMs: number;
routingFallbackUnitCost: number;
routeFailureCooldownMaxValue: number;
routeFailureCooldownMaxUnit: RouteCooldownUnit;
routingWeights: RoutingWeights;
systemProxyUrl: string;
proxyErrorKeywords: string[];
@@ -184,6 +194,35 @@ function inferUrlDialect(connectionString: string): 'mysql' | 'postgres' | 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() {
const navigate = useNavigate();
const isMobile = useIsMobile();
@@ -201,6 +240,8 @@ export default function Settings() {
proxySessionChannelConcurrencyLimit: 2,
proxySessionChannelQueueWaitMs: 1500,
routingFallbackUnitCost: 1,
routeFailureCooldownMaxValue: 30,
routeFailureCooldownMaxUnit: 'day',
routingWeights: defaultWeights,
systemProxyUrl: '',
proxyErrorKeywords: [],
@@ -414,6 +455,7 @@ export default function Settings() {
api.getRuntimeDatabaseConfig(),
]);
setMaskedToken(authInfo.masked || '****');
const routeCooldownInput = resolveRouteCooldownInput(runtimeInfo.tokenRouterFailureCooldownMaxSec);
setRuntime({
checkinCron: runtimeInfo.checkinCron || '0 8 * * *',
checkinScheduleMode: runtimeInfo.checkinScheduleMode === 'interval' ? 'interval' : 'cron',
@@ -438,6 +480,8 @@ export default function Settings() {
routingFallbackUnitCost: Number(runtimeInfo.routingFallbackUnitCost) > 0
? Number(runtimeInfo.routingFallbackUnitCost)
: 1,
routeFailureCooldownMaxValue: routeCooldownInput.value,
routeFailureCooldownMaxUnit: routeCooldownInput.unit,
routingWeights: {
...defaultWeights,
...(runtimeInfo.routingWeights || {}),
@@ -845,6 +889,10 @@ export default function Settings() {
await api.updateRuntimeSettings({
routingWeights: runtime.routingWeights,
routingFallbackUnitCost: runtime.routingFallbackUnitCost,
tokenRouterFailureCooldownMaxSec: toRouteCooldownSeconds(
runtime.routeFailureCooldownMaxValue,
runtime.routeFailureCooldownMaxUnit,
),
disableCrossProtocolFallback: runtime.disableCrossProtocolFallback,
});
toast.success('Routing weights saved');
@@ -1498,6 +1546,50 @@ export default function Settings() {
style={inputStyle}
/>
</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 }}>
<button
onClick={() => applyRoutingPreset('balanced')}
+46
View File
@@ -136,6 +136,7 @@ export default function TokenRoutes() {
const [updatingChannel, setUpdatingChannel] = useState<Record<number, boolean>>({});
const [savingPriorityByRoute, setSavingPriorityByRoute] = 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 [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 isCurrentlyExpanded = expandedRouteIds.includes(routeId);
if (isCurrentlyExpanded) {
@@ -1348,6 +1384,12 @@ export default function TokenRoutes() {
(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
? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null
@@ -1701,6 +1743,8 @@ export default function TokenRoutes() {
onEdit={stableEditRoute}
onDelete={stableDeleteRoute}
onToggleEnabled={stableToggleEnabled}
onClearCooldown={stableClearRouteCooldown}
clearingCooldown={!!clearingCooldownByRoute[route.id]}
onRoutingStrategyChange={stableRoutingStrategyChange}
updatingRoutingStrategy={!!updatingRoutingStrategyByRoute[route.id]}
channels={channelsByRouteId[route.id]}
@@ -1740,6 +1784,8 @@ export default function TokenRoutes() {
onEdit={stableEditRoute}
onDelete={stableDeleteRoute}
onToggleEnabled={stableToggleEnabled}
onClearCooldown={stableClearRouteCooldown}
clearingCooldown={!!clearingCooldownByRoute[route.id]}
onRoutingStrategyChange={stableRoutingStrategyChange}
updatingRoutingStrategy={!!updatingRoutingStrategyByRoute[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()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={undefined}
@@ -85,4 +87,53 @@ describe('RouteCard', () => {
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);
});
});
+14
View File
@@ -53,6 +53,8 @@ type RouteCardProps = {
onEdit: (route: RouteSummaryRow) => void;
onDelete: (routeId: number) => void;
onToggleEnabled: (route: RouteSummaryRow) => void;
onClearCooldown: (routeId: number) => void;
clearingCooldown: boolean;
onRoutingStrategyChange: (route: RouteSummaryRow, strategy: RouteRoutingStrategy) => void;
updatingRoutingStrategy: boolean;
// Channel data (loaded on demand)
@@ -106,6 +108,8 @@ function RouteCardInner({
onEdit,
onDelete,
onToggleEnabled,
onClearCooldown,
clearingCooldown,
onRoutingStrategyChange,
updatingRoutingStrategy,
channels,
@@ -167,6 +171,14 @@ function RouteCardInner({
const priorityBuckets = buildPriorityBuckets(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
if (!expanded) {
@@ -325,6 +337,7 @@ function RouteCardInner({
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{renderClearCooldownButton()}
{!readOnlyRoute && (explicitGroupRoute || !exactRoute) && (
<button onClick={() => onEdit(route)} className="btn btn-link">{tr('编辑群组')}</button>
)}
@@ -358,6 +371,7 @@ function RouteCardInner({
</div>
{!readOnlyRoute && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{renderClearCooldownButton()}
{!exactRoute && (
<button onClick={() => onEdit(route)} className="btn btn-link">{explicitGroupRoute ? tr('编辑群组') : tr('编辑路由')}</button>
)}