forked from boos/BoosAPI
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:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user