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:
@@ -342,6 +342,7 @@ export type RuntimeRoutingWeightsPayload = {
|
||||
export type RuntimeSettingsPayload = {
|
||||
proxyToken?: string;
|
||||
systemProxyUrl?: string;
|
||||
modelAvailabilityProbeEnabled?: boolean;
|
||||
codexUpstreamWebsocketEnabled?: boolean;
|
||||
disableCrossProtocolFallback?: boolean;
|
||||
proxySessionChannelConcurrencyLimit?: number;
|
||||
|
||||
@@ -41,6 +41,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'按成本、延迟、成功率自动选择最优通道,故障自动转移': 'Automatically select the optimal channel based on cost, delay, and success rate, and automatically transfer failures',
|
||||
'按模型过滤': 'Filter by model',
|
||||
'按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)': 'Randomly by weight (W=${weight}, cost=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)}, same-site channel=${siteChannels}, probability≈${(probability * 100).toFixed(1)}%)',
|
||||
'P 值是硬优先级,只会在当前最高可用优先级内结合权重、成本和健康度随机选择': 'P value is a hard priority. Selection stays within the highest available priority tier, then chooses randomly using weight, cost, and health signals.',
|
||||
'保存': 'save',
|
||||
'保存 Cookie': 'Save Cookie',
|
||||
'保存 Cookie 失败': 'Failed to save cookie',
|
||||
@@ -573,6 +574,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'系统会根据模型可用性自动生成路由。优先级按 P0/P1 等桶管理,同一桶内可有多个通道;拖动通道或灰色分隔线即可调整。精确模型路由会自动过滤只支持该模型的账号和令牌。群组路由中的优先级调整会直接回写来源通道。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': 'The system automatically generates routes based on model availability. Priorities are managed as P0/P1 buckets, and multiple channels can share the same bucket; drag channels or the gray separators to adjust them. Exact model routes automatically filter accounts and tokens that only support that model. Priority edits inside group routes write back to the source channels directly. The selection probability indicates the probability of the channel being selected when the request arrives. The priority of cost sources is: measured cost → account configuration cost → catalog reference price → default fallback unit price.',
|
||||
'该群组会将多个来源模型聚合为一个对外模型名;这里调整优先级桶时会直接回写来源通道。若某个来源模型被其他群组复用,保存前会提示影响范围。': 'This group aggregates multiple source models into one public model name. Priority bucket edits here write back to the source channels directly. If a source model is reused by other groups, the impact is shown before saving.',
|
||||
'通配符路由按请求实时决策;下方优先级桶在整条路由内全局生效,来源模型只作为通道标签展示。': 'Wildcard routes decide per request. The priority buckets below apply globally across the whole route, while source models are shown only as channel labels.',
|
||||
'系统会根据模型可用性自动生成路由;精确模型路由会自动过滤只支持该模型的账号和令牌。': 'Routes are auto-generated by model availability; exact model routes auto-filter accounts and tokens that support that model.',
|
||||
'系统设置': 'System settings',
|
||||
'显示调试': 'show debugging',
|
||||
'项目链接': 'Project link',
|
||||
@@ -588,10 +590,16 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'选择站点': 'Select site',
|
||||
'选择账号': 'Select account',
|
||||
'选择账号后同步站点令牌': 'Synchronize site token after selecting account',
|
||||
'选中概率用于解释当前策略下这一次请求更可能落到哪里;轮询和稳定优先更适合把它当作顺序参考。': 'Selection probability explains where this request is more likely to land under the current strategy; for round robin and stable first it is better treated as an order hint.',
|
||||
'先避开最近失败或不健康站点,再在稳定池里按顺序轮询;P 值表示轮询顺位': 'Avoid recently failed or unhealthy sites first, then rotate through the stable pool in order; P value means rotation order.',
|
||||
'讯飞星火': 'iFlytek Spark',
|
||||
'延迟': 'Delay',
|
||||
'验证': 'verify',
|
||||
'验证 Token': 'Verify Token',
|
||||
'只要更高优先级还有可用通道,后面的通道本次就不会参与选择。': 'As long as a higher-priority tier still has available channels, later channels will not participate in this selection.',
|
||||
'忽略 P 值,按全局顺序依次调用;连续失败 3 次后进入分级冷却': 'Ignore P value and call in global order; after 3 consecutive failures the channel enters staged cooldown.',
|
||||
'当前策略不看 P 值;如果之后切回其他策略,拖拽保存的顺序仍会保留。': 'This strategy does not use P value; if you switch back to other strategies later, the saved drag order is still kept.',
|
||||
'当前策略下,稳定站点会按 P 顺序轮换;不稳定站点会被自动降权或临时避让。': 'Under this strategy, stable sites rotate by P order; unstable sites are automatically downweighted or temporarily avoided.',
|
||||
'验证成功: ${result.userInfo?.username || \'未知用户\'}': 'Verification successful: ${result.userInfo?.username || \'未知用户\'}',
|
||||
'验证失败': 'Authentication failed',
|
||||
'验证中...': 'Verifying...',
|
||||
|
||||
@@ -197,6 +197,14 @@ const zhToEn: Record<string, string> = {
|
||||
'系统会根据模型可用性自动生成路由。优先级按 P0/P1 等桶管理,同一桶内可有多个通道;拖动通道或灰色分隔线即可调整。精确模型路由会自动过滤只支持该模型的账号和令牌。群组路由中的优先级调整会直接回写来源通道。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': 'Routes are auto-generated by model availability. Priorities are managed as P0/P1 buckets, and multiple channels can share the same bucket; drag channels or the gray separators to adjust them. Exact model routes auto-filter accounts and tokens that support that model. Priority edits inside group routes write back to the source channels directly. Selection probability is the chance a channel is chosen. Cost priority: measured cost -> account configured cost -> catalog reference price -> default fallback unit price.',
|
||||
'该群组会将多个来源模型聚合为一个对外模型名;这里调整优先级桶时会直接回写来源通道。若某个来源模型被其他群组复用,保存前会提示影响范围。': 'This group aggregates multiple source models into one public model name. Priority bucket edits here write back to the source channels directly. If a source model is reused by other groups, you will be warned about the impact before saving.',
|
||||
'通配符路由按请求实时决策;下方优先级桶在整条路由内全局生效,来源模型只作为通道标签展示。': 'Wildcard routes decide per request. The priority buckets below apply globally across the whole route, while source models are shown only as channel labels.',
|
||||
'系统会根据模型可用性自动生成路由;精确模型路由会自动过滤只支持该模型的账号和令牌。': 'Routes are auto-generated by model availability; exact model routes auto-filter accounts and tokens that support that model.',
|
||||
'P 值是硬优先级,只会在当前最高可用优先级内结合权重、成本和健康度随机选择': 'P value is a hard priority. Selection stays within the highest available priority tier, then chooses randomly using weight, cost, and health signals.',
|
||||
'忽略 P 值,按全局顺序依次调用;连续失败 3 次后进入分级冷却': 'Ignore P value and call in global order; after 3 consecutive failures the channel enters staged cooldown.',
|
||||
'先避开最近失败或不健康站点,再在稳定池里按顺序轮询;P 值表示轮询顺位': 'Avoid recently failed or unhealthy sites first, then rotate through the stable pool in order; P value means rotation order.',
|
||||
'当前策略不看 P 值;如果之后切回其他策略,拖拽保存的顺序仍会保留。': 'This strategy does not use P value; if you switch back to other strategies later, the saved drag order is still kept.',
|
||||
'当前策略下,稳定站点会按 P 顺序轮换;不稳定站点会被自动降权或临时避让。': 'Under this strategy, stable sites rotate by P order; unstable sites are automatically downweighted or temporarily avoided.',
|
||||
'只要更高优先级还有可用通道,后面的通道本次就不会参与选择。': 'As long as a higher-priority tier still has available channels, later channels will not participate in this selection.',
|
||||
'选中概率用于解释当前策略下这一次请求更可能落到哪里;轮询和稳定优先更适合把它当作顺序参考。': 'Selection probability explains where this request is more likely to land under the current strategy; for round robin and stable first it is better treated as an order hint.',
|
||||
'代理端点': 'Proxy Endpoints',
|
||||
'路由行为': 'Routing Behavior',
|
||||
'指标口径': 'Metric Notes',
|
||||
|
||||
@@ -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