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

* wip: preserve local changes before upstream merge

* Improve proxy routing stability and add stable-first strategy

* Sync explicit-group routing strategy to eligible source routes

* Fix proxy retry and runtime health review issues

* fix: make proxy channel retry attempts configurable

* fix: harden proxy retry recovery and bookkeeping

* fix: address pr 257 review comments

* feat(proxy): add sticky session channel coordination

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

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

* fix(proxy): harden codex forwarding stability

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

* fix(server): address probe review feedback

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

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

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

* Finish PR 330 settings and review fixes

* Fix chat site endpoint CI mock

* Address latest PR 330 review comments

---------

Co-authored-by: Cita <juricek.chen@gmail.com>
This commit is contained in:
Bainily
2026-04-02 09:49:04 +08:00
committed by GitHub
parent 1133846998
commit 596d1b93cb
75 changed files with 6365 additions and 507 deletions
+98
View File
@@ -8,6 +8,7 @@ import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import ModernSelect from '../components/ModernSelect.js';
import DownstreamApiKeyModal from './settings/DownstreamApiKeyModal.js';
import FactoryResetModal from './settings/FactoryResetModal.js';
import ModelAvailabilityProbeConfirmModal from './settings/ModelAvailabilityProbeConfirmModal.js';
import RouteSelectorModal from './settings/RouteSelectorModal.js';
import UpdateCenterSection from './settings/UpdateCenterSection.js';
import {
@@ -28,6 +29,7 @@ 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 MODEL_AVAILABILITY_PROBE_CONFIRM_TEXT = '我确认我使用的中转站全部允许批量测活,如因开启此功能被中转站封号,自行负责。';
const SECONDS_PER_DAY = 24 * 60 * 60;
const ROUTE_COOLDOWN_UNIT_OPTIONS = [
{ value: 'second', label: '秒', multiplierSec: 1 },
@@ -58,6 +60,7 @@ type RuntimeSettings = {
logCleanupUsageLogsEnabled: boolean;
logCleanupProgramLogsEnabled: boolean;
logCleanupRetentionDays: number;
modelAvailabilityProbeEnabled: boolean;
codexUpstreamWebsocketEnabled: boolean;
disableCrossProtocolFallback: boolean;
proxySessionChannelConcurrencyLimit: number;
@@ -235,6 +238,7 @@ export default function Settings() {
logCleanupUsageLogsEnabled: false,
logCleanupProgramLogsEnabled: false,
logCleanupRetentionDays: 30,
modelAvailabilityProbeEnabled: false,
codexUpstreamWebsocketEnabled: false,
disableCrossProtocolFallback: false,
proxySessionChannelConcurrencyLimit: 2,
@@ -255,6 +259,7 @@ export default function Settings() {
const [testingCheckin, setTestingCheckin] = useState(false);
const [savingToken, setSavingToken] = useState(false);
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
const [savingModelAvailabilityProbe, setSavingModelAvailabilityProbe] = useState(false);
const [savingProxyTransport, setSavingProxyTransport] = useState(false);
const [testingSystemProxy, setTestingSystemProxy] = useState(false);
const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null);
@@ -300,6 +305,10 @@ export default function Settings() {
const downstreamModalPresence = useAnimatedVisibility(downstreamModalOpen, 220);
const [selectorOpen, setSelectorOpen] = useState(false);
const selectorModalPresence = useAnimatedVisibility(selectorOpen, 220);
const [modelAvailabilityProbeConfirmOpen, setModelAvailabilityProbeConfirmOpen] = useState(false);
const modelAvailabilityProbeConfirmPresence = useAnimatedVisibility(modelAvailabilityProbeConfirmOpen, 220);
const [modelAvailabilityProbeConfirmationInput, setModelAvailabilityProbeConfirmationInput] = useState('');
const [savedModelAvailabilityProbeEnabled, setSavedModelAvailabilityProbeEnabled] = useState(false);
const [factoryResetOpen, setFactoryResetOpen] = useState(false);
const factoryResetPresence = useAnimatedVisibility(factoryResetOpen, 220);
const [factoryResetting, setFactoryResetting] = useState(false);
@@ -377,6 +386,12 @@ export default function Settings() {
}));
}, [migrationDialect]);
useEffect(() => {
if (!modelAvailabilityProbeConfirmOpen) {
setModelAvailabilityProbeConfirmationInput('');
}
}, [modelAvailabilityProbeConfirmOpen]);
useEffect(() => {
if (!factoryResetOpen) {
setFactoryResetSecondsLeft(FACTORY_RESET_CONFIRM_SECONDS);
@@ -469,6 +484,7 @@ export default function Settings() {
logCleanupRetentionDays: Number(runtimeInfo.logCleanupRetentionDays) >= 1
? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays))
: 30,
modelAvailabilityProbeEnabled: !!runtimeInfo.modelAvailabilityProbeEnabled,
codexUpstreamWebsocketEnabled: !!runtimeInfo.codexUpstreamWebsocketEnabled,
disableCrossProtocolFallback: !!runtimeInfo.disableCrossProtocolFallback,
proxySessionChannelConcurrencyLimit: Number(runtimeInfo.proxySessionChannelConcurrencyLimit) >= 0
@@ -499,6 +515,7 @@ export default function Settings() {
globalBlockedBrands: Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : [],
globalAllowedModels: Array.isArray(runtimeInfo.globalAllowedModels) ? runtimeInfo.globalAllowedModels : [],
});
setSavedModelAvailabilityProbeEnabled(!!runtimeInfo.modelAvailabilityProbeEnabled);
setBlockedBrands(Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : []);
setAllowedModels(Array.isArray(runtimeInfo.globalAllowedModels) ? runtimeInfo.globalAllowedModels : []);
setProxyErrorKeywordsText(
@@ -646,6 +663,42 @@ export default function Settings() {
}
};
const persistModelAvailabilityProbeSetting = async (enabled: boolean) => {
setSavingModelAvailabilityProbe(true);
try {
const res = await api.updateRuntimeSettings({
modelAvailabilityProbeEnabled: enabled,
});
const nextEnabled = typeof res?.modelAvailabilityProbeEnabled === 'boolean'
? res.modelAvailabilityProbeEnabled
: enabled;
setRuntime((prev) => ({
...prev,
modelAvailabilityProbeEnabled: nextEnabled,
}));
setSavedModelAvailabilityProbeEnabled(nextEnabled);
setModelAvailabilityProbeConfirmOpen(false);
setModelAvailabilityProbeConfirmationInput('');
toast.success(nextEnabled ? '批量测活已开启' : '批量测活已关闭');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setSavingModelAvailabilityProbe(false);
}
};
const saveModelAvailabilityProbeSettings = async () => {
if (runtime.modelAvailabilityProbeEnabled === savedModelAvailabilityProbeEnabled) {
toast.info('批量测活设置未变化');
return;
}
if (runtime.modelAvailabilityProbeEnabled) {
setModelAvailabilityProbeConfirmOpen(true);
return;
}
await persistModelAvailabilityProbeSetting(false);
};
const saveProxyTransportSettings = async () => {
setSavingProxyTransport(true);
try {
@@ -1009,6 +1062,16 @@ export default function Settings() {
setFactoryResetOpen(false);
};
const closeModelAvailabilityProbeConfirmModal = () => {
if (savingModelAvailabilityProbe) return;
setModelAvailabilityProbeConfirmOpen(false);
};
const handleConfirmModelAvailabilityProbe = async () => {
if (modelAvailabilityProbeConfirmationInput.trim() !== MODEL_AVAILABILITY_PROBE_CONFIRM_TEXT) return;
await persistModelAvailabilityProbeSetting(true);
};
const handleFactoryReset = async () => {
if (factoryResetSecondsLeft > 0 || factoryResetting) return;
setFactoryResetting(true);
@@ -1417,6 +1480,32 @@ export default function Settings() {
</div>
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20, border: '1px solid color-mix(in srgb, var(--color-danger) 24%, var(--color-border))' }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8, color: 'var(--color-danger)' }}>批量测活</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.8 }}>
默认关闭。开启后,metapi 会在后台定时对活跃账号模型发送最小化探测请求,用来校正“/models 能看到但实际不可用”的假阳性。
</div>
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8, marginBottom: 12 }}>
只有在你确认自己使用的中转站明确允许批量测活时才应该开启。若上游不允许,这类探测可能带来封号或风控风险。
</div>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 12 }}>
<input
type="checkbox"
checked={runtime.modelAvailabilityProbeEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, modelAvailabilityProbeEnabled: e.target.checked }))}
/>
允许 metapi 后台主动批量测活
</label>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.7, marginBottom: 12 }}>
当前状态:{savedModelAvailabilityProbeEnabled ? '已启用' : '已关闭'}。首次开启必须手动输入确认语句。
</div>
<div>
<button onClick={saveModelAvailabilityProbeSettings} disabled={savingModelAvailabilityProbe} className="btn btn-primary">
{savingModelAvailabilityProbe ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存批量测活设置'}
</button>
</div>
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
@@ -2108,6 +2197,15 @@ export default function Settings() {
onClose={closeFactoryResetModal}
onConfirm={handleFactoryReset}
/>
<ModelAvailabilityProbeConfirmModal
presence={modelAvailabilityProbeConfirmPresence}
confirmText={MODEL_AVAILABILITY_PROBE_CONFIRM_TEXT}
confirmationInput={modelAvailabilityProbeConfirmationInput}
saving={savingModelAvailabilityProbe}
onConfirmationInputChange={setModelAvailabilityProbeConfirmationInput}
onClose={closeModelAvailabilityProbeConfirmModal}
onConfirm={handleConfirmModelAvailabilityProbe}
/>
<RouteSelectorModal
presence={selectorModalPresence}
loading={selectorLoading}
+8 -2
View File
@@ -25,6 +25,7 @@ import {
import { buildVisibleRouteList } from './helpers/routeListVisibility.js';
import { buildZeroChannelPlaceholderRoutes } from './helpers/zeroChannelRoutes.js';
import {
getRouteRoutingStrategyDescription,
getRouteRoutingStrategyLabel,
normalizeRouteRoutingStrategyValue,
} from './token-routes/routingStrategy.js';
@@ -1565,8 +1566,13 @@ export default function TokenRoutes() {
/>
{/* Info tip */}
<div className="info-tip" style={{ marginBottom: 12 }}>
{tr('系统会根据模型可用性自动生成路由。优先级按 P0/P1 等桶管理,同一桶内可有多个通道;拖动通道或灰色分隔线即可调整。精确模型路由会自动过滤只支持该模型的账号和令牌。群组路由中的优先级调整会直接回写来源通道。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。')}
<div className="info-tip" style={{ marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div>{tr('系统会根据模型可用性自动生成路由。优先级按 P0/P1 等桶管理,同一桶内可有多个通道;拖动通道或灰色分隔线即可调整。精确模型路由会自动过滤只支持该模型的账号和令牌。群组路由中的优先级调整会直接回写来源通道。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。')}</div>
<div>{`${getRouteRoutingStrategyLabel('weighted')}:${getRouteRoutingStrategyDescription('weighted')}`}</div>
<div>{`${getRouteRoutingStrategyLabel('round_robin')}:${getRouteRoutingStrategyDescription('round_robin')}`}</div>
<div>{`${getRouteRoutingStrategyLabel('stable_first')}:${getRouteRoutingStrategyDescription('stable_first')}`}</div>
<div>{tr('选中概率用于解释当前策略下这一次请求更可能落到哪里;轮询和稳定优先更适合把它当作顺序参考。')}</div>
<div>{tr('成本来源优先级:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。')}</div>
</div>
{/* Manual route panel */}
@@ -0,0 +1,157 @@
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 Settings from './Settings.js';
const MODEL_AVAILABILITY_PROBE_CONFIRM_TEXT = '我确认我使用的中转站全部允许批量测活,如因开启此功能被中转站封号,自行负责。';
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 model availability probe confirmation', () => {
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,
modelAvailabilityProbeEnabled: false,
routingFallbackUnitCost: 1,
routingWeights: {},
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,
modelAvailabilityProbeEnabled: true,
});
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
});
afterEach(() => {
vi.clearAllMocks();
});
it('requires the exact confirmation text before enabling model availability probing', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const toggleLabel = root.root.find((node) => (
node.type === 'label'
&& collectText(node).includes('允许 metapi 后台主动批量测活')
));
const toggle = toggleLabel.findByType('input');
expect(toggle.props.checked).toBe(false);
await act(async () => {
toggle.props.onChange({ target: { checked: true } });
});
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(JSON.stringify(root.toJSON())).toContain(MODEL_AVAILABILITY_PROBE_CONFIRM_TEXT);
expect(apiMock.updateRuntimeSettings).not.toHaveBeenCalled();
const confirmButtonBeforeTyping = root.root.find((node) => (
node.type === 'button'
&& collectText(node).trim() === '确认开启批量测活'
&& node.props.className === 'btn btn-danger'
));
expect(confirmButtonBeforeTyping.props.disabled).toBe(true);
const confirmInput = root.root.find((node) => (
node.type === 'textarea'
&& node.props.placeholder === '请输入上方确认语句'
));
await act(async () => {
confirmInput.props.onChange({ target: { value: MODEL_AVAILABILITY_PROBE_CONFIRM_TEXT } });
});
const confirmButton = root.root.find((node) => (
node.type === 'button'
&& collectText(node).trim() === '确认开启批量测活'
&& node.props.className === 'btn btn-danger'
));
expect(confirmButton.props.disabled).toBe(false);
await act(async () => {
confirmButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
modelAvailabilityProbeEnabled: true,
});
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,105 @@
import React from 'react';
import { createPortal } from 'react-dom';
type ModalPresence = {
shouldRender: boolean;
isVisible: boolean;
};
type ModelAvailabilityProbeConfirmModalProps = {
presence: ModalPresence;
confirmText: string;
confirmationInput: string;
saving: boolean;
onConfirmationInputChange: (value: string) => void;
onClose: () => void;
onConfirm: () => void;
};
export default function ModelAvailabilityProbeConfirmModal({
presence,
confirmText,
confirmationInput,
saving,
onConfirmationInputChange,
onClose,
onConfirm,
}: ModelAvailabilityProbeConfirmModalProps) {
if (!presence.shouldRender) {
return null;
}
const canConfirm = confirmationInput.trim() === confirmText;
const titleId = 'model-availability-probe-confirm-modal-title';
const descriptionId = 'model-availability-probe-confirm-modal-description';
const handleRequestClose = () => {
if (saving) return;
onClose();
};
const modal = (
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={handleRequestClose}>
<div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth: 760, border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
aria-busy={saving}
onClick={(e) => e.stopPropagation()}
>
<div id={titleId} className="modal-header" style={{ color: 'var(--color-danger)' }}>确认开启批量测活</div>
<div id={descriptionId} className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
开启后,metapi 会在后台对活跃账号模型做最小化探测请求。这可能被部分中转站视为批量测活或异常行为,请务必先确认你的中转站明确允许此类探测。
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
请手动输入以下整句后再开启:
</div>
<div
style={{
padding: 12,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border-light)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 13,
lineHeight: 1.8,
}}
>
{confirmText}
</div>
<textarea
value={confirmationInput}
onChange={(e) => onConfirmationInputChange(e.target.value)}
placeholder="请输入上方确认语句"
spellCheck={false}
style={{
width: '100%',
minHeight: 96,
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
resize: 'vertical',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
/>
</div>
<div className="modal-footer">
<button onClick={handleRequestClose} disabled={saving} className="btn btn-ghost">取消</button>
<button onClick={onConfirm} disabled={saving || !canConfirm} className="btn btn-danger">
{saving
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 确认开启中...</>
: '确认开启批量测活'}
</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
+20 -4
View File
@@ -34,6 +34,8 @@ import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModel
import { SortableChannelRow } from './SortableChannelRow.js';
import {
getRouteRoutingStrategyLabel,
getRouteRoutingStrategyDescription,
getRouteRoutingStrategyHint,
normalizeRouteRoutingStrategyValue,
} from './routingStrategy.js';
import {
@@ -199,6 +201,8 @@ function RouteCardInner({
const channelManagementDisabled = explicitGroupRoute;
const title = resolveRouteTitle(route);
const routingStrategy = normalizeRouteRoutingStrategyValue(route.routingStrategy);
const routingStrategyDescription = getRouteRoutingStrategyDescription(routingStrategy);
const routingStrategyHint = getRouteRoutingStrategyHint(routingStrategy);
const hasCachedDecisionSnapshot = !!route.decisionSnapshot;
const cachedDecisionTooltip = route.decisionRefreshedAt
? `${tr('最近刷新')}: ${formatDateTimeMinuteLocal(route.decisionRefreshedAt)}`
@@ -207,17 +211,17 @@ function RouteCardInner({
{
value: 'weighted',
label: tr('权重随机'),
description: tr('按优先级、权重和成本信号综合选择'),
description: getRouteRoutingStrategyDescription('weighted'),
},
{
value: 'round_robin',
label: tr('轮询'),
description: tr('按全局顺序轮流调用,忽略优先级,连续失败 3 次后进入分级冷却'),
description: getRouteRoutingStrategyDescription('round_robin'),
},
{
value: 'stable_first',
label: tr('稳定优先'),
description: tr('按优先级优先选择当前最稳、最快、成功率更高的通道,不做随机分流'),
description: getRouteRoutingStrategyDescription('stable_first'),
},
] as const;
@@ -359,7 +363,11 @@ function RouteCardInner({
{tr('0 通道')}
</span>
) : (
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
<span
className="badge badge-muted"
style={{ fontSize: 10, flexShrink: 0 }}
data-tooltip={`${getRouteRoutingStrategyLabel(routingStrategy)}:${routingStrategyDescription}`}
>
{getRouteRoutingStrategyLabel(routingStrategy)}
</span>
)}
@@ -545,6 +553,14 @@ function RouteCardInner({
placeholder={tr('选择路由策略')}
emptyLabel={tr('暂无可选策略')}
/>
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: 12, lineHeight: 1.55, color: 'var(--color-text-secondary)' }}>
{routingStrategyDescription}
</div>
<div style={{ fontSize: 11, lineHeight: 1.5, color: 'var(--color-text-muted)' }}>
{routingStrategyHint}
</div>
</div>
</div>
</div>
)}
@@ -12,3 +12,25 @@ export function getRouteRoutingStrategyLabel(value?: RouteRoutingStrategy | null
if (strategy === 'stable_first') return tr('稳定优先');
return tr('权重随机');
}
export function getRouteRoutingStrategyDescription(value?: RouteRoutingStrategy | null): string {
const strategy = normalizeRouteRoutingStrategyValue(value);
if (strategy === 'round_robin') {
return tr('忽略 P 值,按全局顺序依次调用;连续失败 3 次后进入分级冷却');
}
if (strategy === 'stable_first') {
return tr('先避开最近失败或不健康站点,再在稳定池里按顺序轮询;P 值表示轮询顺位');
}
return tr('P 值是硬优先级,只会在当前最高可用优先级内结合权重、成本和健康度随机选择');
}
export function getRouteRoutingStrategyHint(value?: RouteRoutingStrategy | null): string {
const strategy = normalizeRouteRoutingStrategyValue(value);
if (strategy === 'round_robin') {
return tr('当前策略不看 P 值;如果之后切回其他策略,拖拽保存的顺序仍会保留。');
}
if (strategy === 'stable_first') {
return tr('当前策略下,稳定站点会按 P 顺序轮换;不稳定站点会被自动降权或临时避让。');
}
return tr('只要更高优先级还有可用通道,后面的通道本次就不会参与选择。');
}
@@ -50,7 +50,7 @@ describe('TokenRoutes routing strategy updates', () => {
getBrandMock.mockReset();
getBrandMock.mockReturnValue(null);
apiMock.getRoutesSummary
.mockResolvedValueOnce([
.mockResolvedValue([
{
id: 1,
modelPattern: 'gpt-4o-mini',
@@ -65,8 +65,7 @@ describe('TokenRoutes routing strategy updates', () => {
decisionSnapshot: null,
decisionRefreshedAt: null,
},
])
.mockRejectedValueOnce(new Error('refresh failed'));
]);
apiMock.getRouteChannels.mockResolvedValue([]);
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
@@ -101,11 +100,13 @@ describe('TokenRoutes routing strategy updates', () => {
});
await flushMicrotasks();
apiMock.getRoutesSummary.mockRejectedValueOnce(new Error('refresh failed'));
const roundRobinOption = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
&& collectText(node).includes('轮询')
&& collectText(node).startsWith('轮询')
));
await act(async () => {
@@ -151,11 +152,13 @@ describe('TokenRoutes routing strategy updates', () => {
});
await flushMicrotasks();
apiMock.getRoutesSummary.mockRejectedValueOnce(new Error('refresh failed'));
const stableFirstOption = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
&& collectText(node).includes('稳定优先')
&& collectText(node).startsWith('稳定优先')
));
await act(async () => {
@@ -169,7 +172,7 @@ describe('TokenRoutes routing strategy updates', () => {
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-trigger')
&& collectText(node).includes('稳定优先')
&& collectText(node).startsWith('稳定优先')
));
expect(collectText(strategyTrigger)).toContain('稳定优先');
} finally {