feat(queue): 增加非文本模型分布式准入队列
使用 PostgreSQL 统一同步与异步非文本任务的并发准入、持久化等待和 Worker 容量分配,并将生产 API 与独立 Worker 角色拆分。 补充策略管理、共享契约、OpenAPI、Kubernetes 双节点 Worker 清单及跨节点验收脚本;未默认启用任何生产 queue_size 策略。 已在原基线完成 Go、前端、迁移、Shell、Kustomize 与长任务容量验收;合入最新主干后将重新执行发布门禁。
This commit is contained in:
@@ -498,6 +498,8 @@ export function PlatformManagementPanel(props: {
|
||||
<Label>RPS / 每秒请求数<Input value={form.rpsLimit} placeholder="不填或负数为不限" inputMode="numeric" onChange={(event) => setForm({ ...form, rpsLimit: event.target.value })} /></Label>
|
||||
<Label>TPM / 分钟 Token<Input value={form.tpmLimit} placeholder="不填或负数为不限" inputMode="numeric" onChange={(event) => setForm({ ...form, tpmLimit: event.target.value })} /></Label>
|
||||
<Label>并发请求<Input value={form.concurrencyLimit} placeholder="不填或负数为不限" inputMode="numeric" onChange={(event) => setForm({ ...form, concurrencyLimit: event.target.value })} /></Label>
|
||||
<Label>排队长度<Input value={form.queueSize} placeholder="0 或留空关闭排队" inputMode="numeric" onChange={(event) => setForm({ ...form, queueSize: event.target.value })} /></Label>
|
||||
<Label>最长等待(秒)<Input value={form.queueMaxWaitSeconds} placeholder="默认 600" inputMode="numeric" disabled={!Number(form.queueSize)} onChange={(event) => setForm({ ...form, queueMaxWaitSeconds: event.target.value })} /></Label>
|
||||
<div className="platformTogglePair">
|
||||
<ToggleField checked={form.supportBase64Input} label="支持 Base64 输入" onChange={(checked) => setForm({ ...form, supportBase64Input: checked })} />
|
||||
<ToggleField checked={form.supportUrlInput} label="支持 URL 输入" onChange={(checked) => setForm({ ...form, supportUrlInput: checked })} />
|
||||
@@ -567,6 +569,8 @@ function ModelBindingPolicy(props: { form: PlatformWizardForm; onChange: (value:
|
||||
<Label>模型 RPS<Input value={form.modelRpsLimit} placeholder="不填则不限制" inputMode="numeric" onChange={(event) => onChange({ ...form, modelRpsLimit: event.target.value, modelRateLimitOverrideTouched: true })} /></Label>
|
||||
<Label>模型 TPM<Input value={form.modelTpmLimit} placeholder="不填则不限制" inputMode="numeric" onChange={(event) => onChange({ ...form, modelTpmLimit: event.target.value, modelRateLimitOverrideTouched: true })} /></Label>
|
||||
<Label>模型并发<Input value={form.modelConcurrencyLimit} placeholder="不填则不限制" inputMode="numeric" onChange={(event) => onChange({ ...form, modelConcurrencyLimit: event.target.value, modelRateLimitOverrideTouched: true })} /></Label>
|
||||
<Label>模型排队长度<Input value={form.modelQueueSize} placeholder="0 或留空关闭排队" inputMode="numeric" onChange={(event) => onChange({ ...form, modelQueueSize: event.target.value, modelRateLimitOverrideTouched: true })} /></Label>
|
||||
<Label>模型最长等待(秒)<Input value={form.modelQueueMaxWaitSeconds} placeholder="默认 600" inputMode="numeric" disabled={!Number(form.modelQueueSize)} onChange={(event) => onChange({ ...form, modelQueueMaxWaitSeconds: event.target.value, modelRateLimitOverrideTouched: true })} /></Label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1151,9 +1155,11 @@ function platformToForm(
|
||||
const config = platform.config ?? {};
|
||||
const retryPolicy = platform.retryPolicy ?? {};
|
||||
const rateLimitPolicy = platform.rateLimitPolicy ?? {};
|
||||
const rateLimitParts = splitRateLimitPolicy(rateLimitPolicy);
|
||||
const networkProxy = readNetworkProxyConfig(config);
|
||||
const currentModels = platformModels.filter((model) => model.platformId === platform.id);
|
||||
const modelRateLimitPolicy = currentModels.find((model) => model.rateLimitPolicyMode === 'override')?.rateLimitPolicy ?? {};
|
||||
const modelRateLimitParts = splitRateLimitPolicy(modelRateLimitPolicy);
|
||||
return {
|
||||
...createEmptyPlatformForm(platform.provider, defaults),
|
||||
provider: platform.provider,
|
||||
@@ -1176,6 +1182,11 @@ function platformToForm(
|
||||
rpsLimit: readLimit(rateLimitPolicy, 'rps'),
|
||||
tpmLimit: readLimit(rateLimitPolicy, 'tpm_total'),
|
||||
concurrencyLimit: readLimit(rateLimitPolicy, 'concurrent'),
|
||||
queueSize: readLimit(rateLimitPolicy, 'queue_size'),
|
||||
queueMaxWaitSeconds: readRateLimitValue(rateLimitPolicy, 'queue_size', 'maxWaitSeconds', '600'),
|
||||
rateLimitPolicyExtra: rateLimitParts.extra,
|
||||
rateLimitRuleExtras: rateLimitParts.ruleExtras,
|
||||
rateLimitPreservedRules: rateLimitParts.preservedRules,
|
||||
testMode: readBoolean(config, 'testMode', false),
|
||||
proxyMode: networkProxy.proxyMode,
|
||||
httpProxy: networkProxy.httpProxy,
|
||||
@@ -1190,6 +1201,11 @@ function platformToForm(
|
||||
modelRpsLimit: readLimit(modelRateLimitPolicy, 'rps'),
|
||||
modelTpmLimit: readLimit(modelRateLimitPolicy, 'tpm_total'),
|
||||
modelConcurrencyLimit: readLimit(modelRateLimitPolicy, 'concurrent'),
|
||||
modelQueueSize: readLimit(modelRateLimitPolicy, 'queue_size'),
|
||||
modelQueueMaxWaitSeconds: readRateLimitValue(modelRateLimitPolicy, 'queue_size', 'maxWaitSeconds', '600'),
|
||||
modelRateLimitPolicyExtra: modelRateLimitParts.extra,
|
||||
modelRateLimitRuleExtras: modelRateLimitParts.ruleExtras,
|
||||
modelRateLimitPreservedRules: modelRateLimitParts.preservedRules,
|
||||
selectionMode: 'partial',
|
||||
};
|
||||
}
|
||||
@@ -1283,6 +1299,36 @@ function readLimit(policy: Record<string, unknown>, metric: string) {
|
||||
return typeof rule?.limit === 'number' ? String(rule.limit) : '';
|
||||
}
|
||||
|
||||
function readRateLimitValue(policy: Record<string, unknown>, metric: string, key: string, fallback = '') {
|
||||
const rules = Array.isArray(policy.rules) ? policy.rules : [];
|
||||
const rule = rules.find((item): item is Record<string, unknown> => typeof item === 'object' && item !== null && item.metric === metric);
|
||||
const value = rule?.[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? String(value) : fallback;
|
||||
}
|
||||
|
||||
function splitRateLimitPolicy(policy: Record<string, unknown>) {
|
||||
const editableMetrics = new Set(['rpm', 'rps', 'tpm_total', 'concurrent', 'queue_size']);
|
||||
const extra = { ...policy };
|
||||
delete extra.rules;
|
||||
const ruleExtras: Record<string, Record<string, unknown>> = {};
|
||||
const preservedRules: Array<Record<string, unknown>> = [];
|
||||
const rules = Array.isArray(policy.rules) ? policy.rules : [];
|
||||
rules.forEach((rawRule) => {
|
||||
const rule = readRecord(rawRule);
|
||||
const metric = typeof rule.metric === 'string' ? rule.metric.trim() : '';
|
||||
if (!editableMetrics.has(metric)) {
|
||||
preservedRules.push({ ...rule });
|
||||
return;
|
||||
}
|
||||
const ruleExtra = { ...rule };
|
||||
delete ruleExtra.metric;
|
||||
delete ruleExtra.limit;
|
||||
delete ruleExtra.maxWaitSeconds;
|
||||
ruleExtras[metric] = ruleExtra;
|
||||
});
|
||||
return { extra, ruleExtras, preservedRules };
|
||||
}
|
||||
|
||||
function numberText(value: unknown, fallback: string) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? String(value) : fallback;
|
||||
}
|
||||
|
||||
@@ -356,11 +356,12 @@ export function filterAndSortRealtimeLoad(
|
||||
const cooling = cooldownRemainingMs(status.modelCooldownUntil, options.now) > 0 ||
|
||||
cooldownRemainingMs(status.platformCooldownUntil, options.now) > 0;
|
||||
const disabled = !status.enabled || status.platformStatus !== 'enabled';
|
||||
const queuedTasks = admissionQueueDepth(status);
|
||||
const matchesRuntime = !options.runtimeState ||
|
||||
(options.runtimeState === 'high' && status.loadRatio >= 0.8) ||
|
||||
(options.runtimeState === 'queued' && status.queuedTasks > 0) ||
|
||||
(options.runtimeState === 'queued' && queuedTasks > 0) ||
|
||||
(options.runtimeState === 'attention' && (cooling || disabled)) ||
|
||||
(options.runtimeState === 'normal' && !cooling && !disabled && status.loadRatio < 0.8 && status.queuedTasks <= 0);
|
||||
(options.runtimeState === 'normal' && !cooling && !disabled && status.loadRatio < 0.8 && queuedTasks <= 0);
|
||||
return (!keyword || text.includes(keyword)) &&
|
||||
(!options.platformId || status.platformId === options.platformId) &&
|
||||
(!options.modelType || status.modelType.includes(options.modelType)) &&
|
||||
@@ -375,7 +376,7 @@ export function filterAndSortRealtimeLoad(
|
||||
}
|
||||
|
||||
function realtimeLoadSortValue(status: ModelRateLimitStatus, field: RealtimeLoadSortField) {
|
||||
if (field === 'queued') return status.queuedTasks;
|
||||
if (field === 'queued') return admissionQueueDepth(status);
|
||||
if (field === 'concurrent') return status.concurrent.currentValue;
|
||||
if (field === 'tpm') return status.tpm.currentValue;
|
||||
if (field === 'rpm') return status.rpm.currentValue;
|
||||
@@ -383,6 +384,13 @@ function realtimeLoadSortValue(status: ModelRateLimitStatus, field: RealtimeLoad
|
||||
return status.loadRatio;
|
||||
}
|
||||
|
||||
function admissionQueueDepth(status: ModelRateLimitStatus) {
|
||||
if (status.waitingSyncTasks !== undefined || status.waitingAsyncTasks !== undefined) {
|
||||
return (status.waitingSyncTasks ?? 0) + (status.waitingAsyncTasks ?? 0);
|
||||
}
|
||||
return status.queuedTasks ?? 0;
|
||||
}
|
||||
|
||||
function platformDisplayName(platform: IntegrationPlatform) {
|
||||
return platform.internalName?.trim() || platform.name;
|
||||
}
|
||||
@@ -627,11 +635,18 @@ function metricCell(metric: ModelRateLimitStatus['rpm'], includeReserved = false
|
||||
}
|
||||
|
||||
function concurrencyMetricCell(status: ModelRateLimitStatus) {
|
||||
const queuedTasks = status.queuedTasks ?? 0;
|
||||
const hasAdmissionBreakdown = status.waitingSyncTasks !== undefined || status.waitingAsyncTasks !== undefined;
|
||||
const queuedTasks = hasAdmissionBreakdown
|
||||
? (status.waitingSyncTasks ?? 0) + (status.waitingAsyncTasks ?? 0)
|
||||
: (status.queuedTasks ?? 0);
|
||||
const limitText = status.concurrent.limited ? formatLimit(status.concurrent.limitValue) : '不限';
|
||||
const queueText = status.queueLimit
|
||||
? `${formatLimit(queuedTasks)} / ${formatLimit(status.queueLimit)}`
|
||||
: formatLimit(queuedTasks);
|
||||
return (
|
||||
<span className="rateMetricCell" title="正在执行 / 并发上限 / 排队任务">
|
||||
<strong>{formatLimit(status.concurrent.currentValue)} / {limitText} / {formatLimit(queuedTasks)}</strong>
|
||||
<span className="rateMetricCell" title={`正在执行 / 并发上限 / 排队任务${status.oldestQueueWaitSeconds ? `;最老等待 ${Math.round(status.oldestQueueWaitSeconds)} 秒` : ''}`}>
|
||||
<strong>{formatLimit(status.concurrent.currentValue)} / {limitText} / {queueText}</strong>
|
||||
{hasAdmissionBreakdown && <small>同步 {status.waitingSyncTasks ?? 0} / 异步 {status.waitingAsyncTasks ?? 0}</small>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { RuntimePolicySet } from '@easyai-ai-gateway/contracts';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formToPayload, policyToForm } from './RuntimePoliciesPanel';
|
||||
|
||||
describe('runtime queue policy editor', () => {
|
||||
it('preserves unknown policy extensions while editing queue values', () => {
|
||||
const policy = {
|
||||
id: 'runtime-1',
|
||||
policyKey: 'runtime-image',
|
||||
name: 'Image runtime',
|
||||
status: 'active',
|
||||
rateLimitPolicy: {
|
||||
extensionMode: 'legacy',
|
||||
rules: [
|
||||
{ metric: 'concurrent', limit: 2, leaseTtlSeconds: 90, vendorHint: 'keep' },
|
||||
{ metric: 'queue_size', limit: 4, maxWaitSeconds: 120, vendorQueueMode: 'shared' },
|
||||
{ metric: 'rps', limit: 5, windowSeconds: 1, vendorWindow: true },
|
||||
],
|
||||
},
|
||||
retryPolicy: {},
|
||||
autoDisablePolicy: {},
|
||||
degradePolicy: {},
|
||||
metadata: {},
|
||||
createdAt: '2026-07-29T00:00:00Z',
|
||||
updatedAt: '2026-07-29T00:00:00Z',
|
||||
} as RuntimePolicySet;
|
||||
|
||||
const form = policyToForm(policy);
|
||||
const payload = formToPayload({
|
||||
...form,
|
||||
queueSize: '6',
|
||||
queueMaxWaitSeconds: '300',
|
||||
});
|
||||
|
||||
expect(payload.rateLimitPolicy).toEqual({
|
||||
extensionMode: 'legacy',
|
||||
rules: [
|
||||
{ metric: 'rps', limit: 5, windowSeconds: 1, vendorWindow: true },
|
||||
{
|
||||
vendorHint: 'keep',
|
||||
metric: 'concurrent',
|
||||
limit: 2,
|
||||
windowSeconds: 60,
|
||||
leaseTtlSeconds: 90,
|
||||
},
|
||||
{
|
||||
vendorQueueMode: 'shared',
|
||||
metric: 'queue_size',
|
||||
limit: 6,
|
||||
maxWaitSeconds: 300,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,13 +26,18 @@ type RunnerActionRule = {
|
||||
cooldownSeconds: string;
|
||||
};
|
||||
|
||||
type RuntimePolicyForm = {
|
||||
export type RuntimePolicyForm = {
|
||||
policyKey: string;
|
||||
name: string;
|
||||
description: string;
|
||||
rpm: string;
|
||||
tpm: string;
|
||||
concurrency: string;
|
||||
queueSize: string;
|
||||
queueMaxWaitSeconds: string;
|
||||
rateLimitPolicyExtra: Record<string, unknown>;
|
||||
rateLimitRuleExtras: Record<string, Record<string, unknown>>;
|
||||
rateLimitPreservedRules: Array<Record<string, unknown>>;
|
||||
retryEnabled: boolean;
|
||||
retryMaxAttempts: string;
|
||||
retryAllowKeywords: string[];
|
||||
@@ -275,11 +280,13 @@ export function RuntimePoliciesPanel(props: {
|
||||
<Label className="spanTwo">描述<Input value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Label>
|
||||
|
||||
<section className="runtimePolicySection spanTwo">
|
||||
<header><strong>限流策略</strong><span>TPM / RPM / 并发</span></header>
|
||||
<header><strong>限流策略</strong><span>TPM / RPM / 并发 / 非文本任务排队</span></header>
|
||||
<div className="runtimePolicyRows">
|
||||
<Label>RPM / 分钟请求<Input value={form.rpm} placeholder="不填或负数为不限" inputMode="numeric" onChange={(event) => setForm({ ...form, rpm: event.target.value })} /></Label>
|
||||
<Label>TPM / 分钟 Token<Input value={form.tpm} placeholder="不填或负数为不限" inputMode="numeric" onChange={(event) => setForm({ ...form, tpm: event.target.value })} /></Label>
|
||||
<Label>并发请求<Input value={form.concurrency} placeholder="不填或负数为不限" inputMode="numeric" onChange={(event) => setForm({ ...form, concurrency: event.target.value })} /></Label>
|
||||
<Label>排队长度<Input value={form.queueSize} placeholder="0 或留空关闭排队" inputMode="numeric" onChange={(event) => setForm({ ...form, queueSize: event.target.value })} /></Label>
|
||||
<Label>最长等待(秒)<Input value={form.queueMaxWaitSeconds} placeholder="默认 600" inputMode="numeric" disabled={!Number(form.queueSize)} onChange={(event) => setForm({ ...form, queueMaxWaitSeconds: event.target.value })} /></Label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -615,6 +622,11 @@ function createDefaultForm(policyKey = 'default-runtime-v1'): RuntimePolicyForm
|
||||
rpm: '120',
|
||||
tpm: '240000',
|
||||
concurrency: '6',
|
||||
queueSize: '',
|
||||
queueMaxWaitSeconds: '600',
|
||||
rateLimitPolicyExtra: {},
|
||||
rateLimitRuleExtras: {},
|
||||
rateLimitPreservedRules: [],
|
||||
retryEnabled: true,
|
||||
retryMaxAttempts: '2',
|
||||
retryAllowKeywords: ['rate_limit', 'timeout', 'server_error', 'network', '429', '5xx'],
|
||||
@@ -949,8 +961,9 @@ function defaultRunnerActionTarget(action: RunnerFailoverAction): RunnerFailover
|
||||
return action === 'cooldown_and_next' ? 'model' : 'platform';
|
||||
}
|
||||
|
||||
function policyToForm(policy: RuntimePolicySet): RuntimePolicyForm {
|
||||
export function policyToForm(policy: RuntimePolicySet): RuntimePolicyForm {
|
||||
const rateRules = Array.isArray(policy.rateLimitPolicy?.rules) ? policy.rateLimitPolicy.rules : [];
|
||||
const rateLimitParts = splitRuntimeRateLimitPolicy(readObject(policy.rateLimitPolicy));
|
||||
const retry = readObject(policy.retryPolicy);
|
||||
const disable = readObject(policy.autoDisablePolicy);
|
||||
const degrade = readObject(policy.degradePolicy);
|
||||
@@ -961,6 +974,11 @@ function policyToForm(policy: RuntimePolicySet): RuntimePolicyForm {
|
||||
rpm: formRateLimitText(readRateLimit(rateRules, 'rpm')),
|
||||
tpm: formRateLimitText(readRateLimit(rateRules, 'tpm_total')),
|
||||
concurrency: formRateLimitText(readRateLimit(rateRules, 'concurrent')),
|
||||
queueSize: formRateLimitText(readRateLimit(rateRules, 'queue_size')),
|
||||
queueMaxWaitSeconds: formRateLimitPropertyText(readRateLimitRule(rateRules, 'queue_size'), 'maxWaitSeconds', '600'),
|
||||
rateLimitPolicyExtra: rateLimitParts.extra,
|
||||
rateLimitRuleExtras: rateLimitParts.ruleExtras,
|
||||
rateLimitPreservedRules: rateLimitParts.preservedRules,
|
||||
retryEnabled: readBool(retry.enabled, true),
|
||||
retryMaxAttempts: String(readNumber(retry.maxAttempts, 2)),
|
||||
retryAllowKeywords: tagsFromValue(retry.allowKeywords),
|
||||
@@ -976,12 +994,15 @@ function policyToForm(policy: RuntimePolicySet): RuntimePolicyForm {
|
||||
};
|
||||
}
|
||||
|
||||
function formToPayload(form: RuntimePolicyForm): RuntimePolicySetUpsertRequest {
|
||||
export function formToPayload(form: RuntimePolicyForm): RuntimePolicySetUpsertRequest {
|
||||
return {
|
||||
policyKey: form.policyKey.trim(),
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
rateLimitPolicy: { rules: rateLimitRules(form) },
|
||||
rateLimitPolicy: {
|
||||
...form.rateLimitPolicyExtra,
|
||||
rules: rateLimitRules(form),
|
||||
},
|
||||
retryPolicy: {
|
||||
enabled: form.retryEnabled,
|
||||
maxAttempts: positiveInt(form.retryMaxAttempts, 2),
|
||||
@@ -1005,16 +1026,54 @@ function formToPayload(form: RuntimePolicyForm): RuntimePolicySetUpsertRequest {
|
||||
|
||||
function rateLimitRules(form: RuntimePolicyForm) {
|
||||
return [
|
||||
limitRule('rpm', form.rpm),
|
||||
limitRule('tpm_total', form.tpm),
|
||||
limitRule('concurrent', form.concurrency, 60, 120),
|
||||
...form.rateLimitPreservedRules,
|
||||
limitRule('rpm', form.rpm, 60, undefined, form.rateLimitRuleExtras.rpm),
|
||||
limitRule('tpm_total', form.tpm, 60, undefined, form.rateLimitRuleExtras.tpm_total),
|
||||
limitRule('concurrent', form.concurrency, 60, 120, form.rateLimitRuleExtras.concurrent),
|
||||
queueLimitRule(form.queueSize, form.queueMaxWaitSeconds, form.rateLimitRuleExtras.queue_size),
|
||||
].filter((rule): rule is NonNullable<ReturnType<typeof limitRule>> => Boolean(rule));
|
||||
}
|
||||
|
||||
function limitRule(metric: string, value: string, windowSeconds = 60, leaseTtlSeconds?: number) {
|
||||
function queueLimitRule(queueSize: string, maxWaitSeconds: string, extra: Record<string, unknown> = {}) {
|
||||
if (!queueSize.trim() || Number(queueSize) === 0) return undefined;
|
||||
const limit = boundedPositiveInt(queueSize, '排队长度', 10000);
|
||||
const maxWait = maxWaitSeconds.trim() ? boundedPositiveInt(maxWaitSeconds, '最长等待', 3600) : 600;
|
||||
return { ...extra, metric: 'queue_size', limit, maxWaitSeconds: maxWait };
|
||||
}
|
||||
|
||||
function limitRule(metric: string, value: string, windowSeconds = 60, leaseTtlSeconds?: number, extra: Record<string, unknown> = {}) {
|
||||
const limit = Number(value);
|
||||
if (!Number.isFinite(limit) || limit <= 0) return undefined;
|
||||
return { metric, limit, windowSeconds, leaseTtlSeconds };
|
||||
return {
|
||||
...extra,
|
||||
metric,
|
||||
limit,
|
||||
windowSeconds: typeof extra.windowSeconds === 'number' ? extra.windowSeconds : windowSeconds,
|
||||
leaseTtlSeconds: typeof extra.leaseTtlSeconds === 'number' ? extra.leaseTtlSeconds : leaseTtlSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function splitRuntimeRateLimitPolicy(policy: Record<string, unknown>) {
|
||||
const editableMetrics = new Set(['rpm', 'tpm_total', 'concurrent', 'queue_size']);
|
||||
const extra = { ...policy };
|
||||
delete extra.rules;
|
||||
const ruleExtras: Record<string, Record<string, unknown>> = {};
|
||||
const preservedRules: Array<Record<string, unknown>> = [];
|
||||
const rules = Array.isArray(policy.rules) ? policy.rules : [];
|
||||
rules.forEach((rawRule) => {
|
||||
const rule = readObject(rawRule);
|
||||
const metric = typeof rule.metric === 'string' ? rule.metric.trim() : '';
|
||||
if (!editableMetrics.has(metric)) {
|
||||
preservedRules.push({ ...rule });
|
||||
return;
|
||||
}
|
||||
const ruleExtra = { ...rule };
|
||||
delete ruleExtra.metric;
|
||||
delete ruleExtra.limit;
|
||||
delete ruleExtra.maxWaitSeconds;
|
||||
ruleExtras[metric] = ruleExtra;
|
||||
});
|
||||
return { extra, ruleExtras, preservedRules };
|
||||
}
|
||||
|
||||
function isDefaultPolicy(policy: RuntimePolicySet) {
|
||||
@@ -1026,7 +1085,9 @@ function rateLimitSummary(policy: RuntimePolicySet) {
|
||||
const rpm = rateLimitText(readRateLimit(rules, 'rpm'));
|
||||
const tpm = rateLimitText(readRateLimit(rules, 'tpm_total'));
|
||||
const concurrent = rateLimitText(readRateLimit(rules, 'concurrent'));
|
||||
return `RPM ${rpm} / TPM ${tpm} / 并发 ${concurrent}`;
|
||||
const queue = readRateLimit(rules, 'queue_size');
|
||||
const queueText = queue ? ` / 排队 ${rateLimitText(queue)} / 最长 ${formRateLimitPropertyText(readRateLimitRule(rules, 'queue_size'), 'maxWaitSeconds', '600')}秒` : '';
|
||||
return `RPM ${rpm} / TPM ${tpm} / 并发 ${concurrent}${queueText}`;
|
||||
}
|
||||
|
||||
function retrySummary(policy: RuntimePolicySet) {
|
||||
@@ -1045,11 +1106,20 @@ function degradeSummary(policy: RuntimePolicySet) {
|
||||
}
|
||||
|
||||
function readRateLimit(rules: unknown[], metric: string) {
|
||||
const rule = rules.find((item) => readObject(item).metric === metric);
|
||||
const rule = readRateLimitRule(rules, metric);
|
||||
const limit = Number(readObject(rule).limit);
|
||||
return Number.isFinite(limit) ? limit : undefined;
|
||||
}
|
||||
|
||||
function readRateLimitRule(rules: unknown[], metric: string) {
|
||||
return rules.find((item) => readObject(item).metric === metric);
|
||||
}
|
||||
|
||||
function formRateLimitPropertyText(rule: unknown, key: string, fallback = '') {
|
||||
const value = Number(readObject(rule)[key]);
|
||||
return Number.isFinite(value) && value > 0 ? String(value) : fallback;
|
||||
}
|
||||
|
||||
function formRateLimitText(value: number | undefined) {
|
||||
return value === undefined ? '' : String(value);
|
||||
}
|
||||
@@ -1090,6 +1160,14 @@ function positiveInt(value: string, fallback: number) {
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function boundedPositiveInt(value: string, label: string, max: number) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) {
|
||||
throw new Error(`${label}必须是 1–${max} 的整数`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
|
||||
@@ -34,6 +34,31 @@ describe('user group policy editors', () => {
|
||||
expect(() => rateLimitPolicyFromForm(form.rules, form.extra)).toThrow('不能重复');
|
||||
});
|
||||
|
||||
it('normalizes zero queues and round-trips queue wait settings with extensions', () => {
|
||||
const source = {
|
||||
rules: [
|
||||
{ metric: 'queue_size' as const, limit: 8, maxWaitSeconds: 75, source: 'custom' },
|
||||
],
|
||||
};
|
||||
const form = rateLimitPolicyToForm(source);
|
||||
expect(rateLimitPolicyFromForm(form.rules, form.extra)).toEqual(source);
|
||||
expect(rateLimitPolicySummary(source)).toEqual(['排队 8 / 75秒']);
|
||||
|
||||
form.rules[0].limit = '0';
|
||||
expect(rateLimitPolicyFromForm(form.rules, form.extra)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('defaults queue wait to 600 seconds and validates upper bounds', () => {
|
||||
const form = rateLimitPolicyToForm({
|
||||
rules: [{ metric: 'queue_size', limit: 2 }],
|
||||
});
|
||||
expect(rateLimitPolicyFromForm(form.rules, form.extra)).toEqual({
|
||||
rules: [{ metric: 'queue_size', limit: 2, maxWaitSeconds: 600 }],
|
||||
});
|
||||
form.rules[0].maxWaitSeconds = '3601';
|
||||
expect(() => rateLimitPolicyFromForm(form.rules, form.extra)).toThrow('1–3600');
|
||||
});
|
||||
|
||||
it('round-trips quota values without changing their types', () => {
|
||||
const source = {
|
||||
monthlyResource: 10000,
|
||||
|
||||
@@ -8,6 +8,7 @@ export type UserGroupRateLimitRuleForm = {
|
||||
limit: string;
|
||||
windowSeconds: string;
|
||||
leaseTtlSeconds: string;
|
||||
maxWaitSeconds: string;
|
||||
consume: string;
|
||||
extra: Record<string, unknown>;
|
||||
};
|
||||
@@ -59,18 +60,34 @@ export function UserGroupRateLimitEditor(props: {
|
||||
<span role="columnheader">上限</span>
|
||||
<span role="columnheader">窗口(秒)</span>
|
||||
<span role="columnheader">租约 TTL(秒)</span>
|
||||
<span role="columnheader">最长等待(秒)</span>
|
||||
<span role="columnheader">消费方式</span>
|
||||
<span role="columnheader">操作</span>
|
||||
</div>
|
||||
{props.value.map((rule) => (
|
||||
<div className="userGroupPolicyRow" role="row" key={rule.id}>
|
||||
<Select aria-label="限流指标" size="sm" value={rule.metric} onChange={(event) => update(rule.id, { metric: event.target.value as RateLimitMetric })}>
|
||||
<Select
|
||||
aria-label="限流指标"
|
||||
size="sm"
|
||||
value={rule.metric}
|
||||
onChange={(event) => {
|
||||
const metric = event.target.value as RateLimitMetric;
|
||||
update(rule.id, {
|
||||
metric,
|
||||
windowSeconds: metric === 'queue_size' || metric === 'concurrent' ? '' : (rule.windowSeconds || '60'),
|
||||
leaseTtlSeconds: metric === 'concurrent' ? (rule.leaseTtlSeconds || '120') : '',
|
||||
maxWaitSeconds: metric === 'queue_size' ? (rule.maxWaitSeconds || '600') : '',
|
||||
consume: metric === 'queue_size' ? '' : rule.consume,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{rateLimitMetrics.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
|
||||
</Select>
|
||||
<Input aria-label={`${metricLabel(rule.metric)}上限`} size="sm" inputMode="decimal" value={rule.limit} placeholder="不限" onChange={(event) => update(rule.id, { limit: event.target.value })} />
|
||||
<Input aria-label={`${metricLabel(rule.metric)}窗口秒数`} size="sm" inputMode="numeric" value={rule.windowSeconds} placeholder={rule.metric === 'concurrent' ? '-' : '60'} onChange={(event) => update(rule.id, { windowSeconds: event.target.value })} />
|
||||
<Input aria-label={`${metricLabel(rule.metric)}租约 TTL`} size="sm" inputMode="numeric" value={rule.leaseTtlSeconds} placeholder={rule.metric === 'concurrent' ? '120' : '-'} onChange={(event) => update(rule.id, { leaseTtlSeconds: event.target.value })} />
|
||||
<Select aria-label={`${metricLabel(rule.metric)}消费方式`} size="sm" value={rule.consume} onChange={(event) => update(rule.id, { consume: event.target.value })}>
|
||||
<Input aria-label={`${metricLabel(rule.metric)}窗口秒数`} size="sm" inputMode="numeric" value={rule.windowSeconds} placeholder={rule.metric === 'queue_size' || rule.metric === 'concurrent' ? '-' : '60'} disabled={rule.metric === 'queue_size' || rule.metric === 'concurrent'} onChange={(event) => update(rule.id, { windowSeconds: event.target.value })} />
|
||||
<Input aria-label={`${metricLabel(rule.metric)}租约 TTL`} size="sm" inputMode="numeric" value={rule.leaseTtlSeconds} placeholder={rule.metric === 'concurrent' ? '120' : '-'} disabled={rule.metric !== 'concurrent'} onChange={(event) => update(rule.id, { leaseTtlSeconds: event.target.value })} />
|
||||
<Input aria-label={`${metricLabel(rule.metric)}最长等待`} size="sm" inputMode="numeric" value={rule.maxWaitSeconds} placeholder={rule.metric === 'queue_size' ? '600' : '-'} disabled={rule.metric !== 'queue_size'} onChange={(event) => update(rule.id, { maxWaitSeconds: event.target.value })} />
|
||||
<Select aria-label={`${metricLabel(rule.metric)}消费方式`} size="sm" value={rule.consume} disabled={rule.metric === 'queue_size'} onChange={(event) => update(rule.id, { consume: event.target.value })}>
|
||||
<option value="">默认</option>
|
||||
<option value="fixed_window">固定窗口</option>
|
||||
<option value="reserve_then_reconcile">预占后核销</option>
|
||||
@@ -172,6 +189,7 @@ export function rateLimitPolicyToForm(policy?: RateLimitPolicy) {
|
||||
delete ruleExtra.limit;
|
||||
delete ruleExtra.windowSeconds;
|
||||
delete ruleExtra.leaseTtlSeconds;
|
||||
delete ruleExtra.maxWaitSeconds;
|
||||
delete ruleExtra.consume;
|
||||
return {
|
||||
id: nextPolicyEditorRowId('rate'),
|
||||
@@ -179,6 +197,7 @@ export function rateLimitPolicyToForm(policy?: RateLimitPolicy) {
|
||||
limit: numberText(rule.limit),
|
||||
windowSeconds: numberText(rule.windowSeconds),
|
||||
leaseTtlSeconds: numberText(rule.leaseTtlSeconds),
|
||||
maxWaitSeconds: rule.metric === 'queue_size' ? numberText(rule.maxWaitSeconds) || '600' : '',
|
||||
consume: typeof rule.consume === 'string' ? rule.consume : '',
|
||||
extra: ruleExtra,
|
||||
};
|
||||
@@ -193,17 +212,24 @@ export function rateLimitPolicyFromForm(
|
||||
const metrics = new Set<string>();
|
||||
const normalizedRules = rules.flatMap((rule) => {
|
||||
if (!rule.limit.trim()) return [];
|
||||
if (rule.metric === 'queue_size' && Number(rule.limit) === 0) return [];
|
||||
if (metrics.has(rule.metric)) throw new Error(`限流指标 ${metricLabel(rule.metric)} 不能重复`);
|
||||
metrics.add(rule.metric);
|
||||
const limit = positiveNumber(rule.limit, `${metricLabel(rule.metric)}上限`);
|
||||
const windowSeconds = optionalPositiveInteger(rule.windowSeconds, `${metricLabel(rule.metric)}窗口`);
|
||||
const leaseTtlSeconds = optionalPositiveInteger(rule.leaseTtlSeconds, `${metricLabel(rule.metric)}租约 TTL`);
|
||||
const limit = rule.metric === 'queue_size'
|
||||
? boundedPositiveInteger(rule.limit, `${metricLabel(rule.metric)}上限`, 10000)
|
||||
: positiveNumber(rule.limit, `${metricLabel(rule.metric)}上限`);
|
||||
const windowSeconds = rule.metric === 'queue_size' ? undefined : optionalPositiveInteger(rule.windowSeconds, `${metricLabel(rule.metric)}窗口`);
|
||||
const leaseTtlSeconds = rule.metric === 'queue_size' ? undefined : optionalPositiveInteger(rule.leaseTtlSeconds, `${metricLabel(rule.metric)}租约 TTL`);
|
||||
const maxWaitSeconds = rule.metric === 'queue_size'
|
||||
? boundedPositiveInteger(rule.maxWaitSeconds || '600', `${metricLabel(rule.metric)}最长等待`, 3600)
|
||||
: undefined;
|
||||
return [{
|
||||
...rule.extra,
|
||||
metric: rule.metric,
|
||||
limit,
|
||||
...(windowSeconds ? { windowSeconds } : {}),
|
||||
...(leaseTtlSeconds ? { leaseTtlSeconds } : {}),
|
||||
...(maxWaitSeconds ? { maxWaitSeconds } : {}),
|
||||
...(rule.consume ? { consume: rule.consume } : {}),
|
||||
}];
|
||||
});
|
||||
@@ -242,7 +268,9 @@ export function rateLimitPolicySummary(policy?: RateLimitPolicy) {
|
||||
const rules = Array.isArray(policy?.rules) ? policy.rules : [];
|
||||
const parts = rules
|
||||
.filter((rule) => Number.isFinite(Number(rule.limit)) && Number(rule.limit) > 0)
|
||||
.map((rule) => `${metricShortLabel(rule.metric)} ${compactNumber(Number(rule.limit))}`);
|
||||
.map((rule) => rule.metric === 'queue_size'
|
||||
? `${metricShortLabel(rule.metric)} ${compactNumber(Number(rule.limit))} / ${Number(rule.maxWaitSeconds) || 600}秒`
|
||||
: `${metricShortLabel(rule.metric)} ${compactNumber(Number(rule.limit))}`);
|
||||
if (parts.length) return parts;
|
||||
return Object.entries(policy ?? {})
|
||||
.filter(([key]) => key !== 'rules')
|
||||
@@ -261,6 +289,7 @@ function createRateLimitRuleForm(metric: RateLimitMetric): UserGroupRateLimitRul
|
||||
limit: '',
|
||||
windowSeconds: metric === 'concurrent' ? '' : '60',
|
||||
leaseTtlSeconds: metric === 'concurrent' ? '120' : '',
|
||||
maxWaitSeconds: metric === 'queue_size' ? '600' : '',
|
||||
consume: '',
|
||||
extra: {},
|
||||
};
|
||||
@@ -321,6 +350,14 @@ function optionalPositiveInteger(value: string, label: string) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function boundedPositiveInteger(value: string, label: string, max: number) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) {
|
||||
throw new Error(`${label}必须是 1–${max} 的整数`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function serializeQuotaValue(value: unknown): Pick<UserGroupQuotaEntryForm, 'valueType' | 'value'> {
|
||||
if (typeof value === 'number') return { valueType: 'number', value: String(value) };
|
||||
if (typeof value === 'boolean') return { valueType: 'boolean', value: String(value) };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BaseModelCatalogItem } from '@easyai-ai-gateway/contracts';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createEmptyPlatformForm, platformModelPayloads } from './platform-form';
|
||||
import { createEmptyPlatformForm, platformModelPayloads, platformPayload, validatePlatformForm } from './platform-form';
|
||||
|
||||
const baseModel = {
|
||||
id: 'model-1',
|
||||
@@ -53,4 +53,78 @@ describe('platform model rate limit payload', () => {
|
||||
expect(payload.rateLimitPolicyMode).toBe('override');
|
||||
expect(payload.rateLimitPolicy).toEqual({ rules: [] });
|
||||
});
|
||||
|
||||
it('serializes queue length and defaults maximum wait to 600 seconds', () => {
|
||||
const form = {
|
||||
...selectedForm(),
|
||||
name: 'Queue platform',
|
||||
baseUrl: 'https://example.test',
|
||||
testMode: true,
|
||||
queueSize: '12',
|
||||
queueMaxWaitSeconds: '',
|
||||
modelOverrideRateLimit: true,
|
||||
modelRateLimitOverrideTouched: true,
|
||||
modelQueueSize: '4',
|
||||
modelQueueMaxWaitSeconds: '90',
|
||||
};
|
||||
|
||||
expect(platformPayload(form).rateLimitPolicy).toEqual({
|
||||
rules: [{ metric: 'queue_size', limit: 12, maxWaitSeconds: 600 }],
|
||||
});
|
||||
expect(platformModelPayloads([baseModel], form)[0].rateLimitPolicy).toEqual({
|
||||
rules: [{ metric: 'queue_size', limit: 4, maxWaitSeconds: 90 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects queue values outside the shared policy bounds', () => {
|
||||
const form = {
|
||||
...selectedForm(),
|
||||
name: 'Queue platform',
|
||||
baseUrl: 'https://example.test',
|
||||
testMode: true,
|
||||
queueSize: '10001',
|
||||
};
|
||||
expect(validatePlatformForm(form, 1)).toContain('1–10000');
|
||||
expect(validatePlatformForm({ ...form, queueSize: '2', queueMaxWaitSeconds: '3601' }, 1)).toContain('1–3600');
|
||||
});
|
||||
|
||||
it('preserves unknown policy fields and non-editable rules', () => {
|
||||
const form = {
|
||||
...selectedForm(),
|
||||
name: 'Compatible platform',
|
||||
baseUrl: 'https://example.test',
|
||||
testMode: true,
|
||||
concurrencyLimit: '3',
|
||||
queueSize: '6',
|
||||
rateLimitPolicyExtra: { extensionMode: 'legacy' },
|
||||
rateLimitRuleExtras: {
|
||||
concurrent: { consume: 'lease', vendorHint: 'keep' },
|
||||
queue_size: { vendorQueueMode: 'shared' },
|
||||
},
|
||||
rateLimitPreservedRules: [
|
||||
{ metric: 'tpm_input', limit: 1234, vendorWindow: true },
|
||||
],
|
||||
};
|
||||
|
||||
expect(platformPayload(form).rateLimitPolicy).toEqual({
|
||||
extensionMode: 'legacy',
|
||||
rules: [
|
||||
{ metric: 'tpm_input', limit: 1234, vendorWindow: true },
|
||||
{
|
||||
consume: 'lease',
|
||||
vendorHint: 'keep',
|
||||
metric: 'concurrent',
|
||||
limit: 3,
|
||||
windowSeconds: 60,
|
||||
leaseTtlSeconds: 120,
|
||||
},
|
||||
{
|
||||
vendorQueueMode: 'shared',
|
||||
metric: 'queue_size',
|
||||
limit: 6,
|
||||
maxWaitSeconds: 600,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,11 @@ export interface PlatformWizardForm {
|
||||
rpsLimit: string;
|
||||
tpmLimit: string;
|
||||
concurrencyLimit: string;
|
||||
queueSize: string;
|
||||
queueMaxWaitSeconds: string;
|
||||
rateLimitPolicyExtra: Record<string, unknown>;
|
||||
rateLimitRuleExtras: Record<string, Record<string, unknown>>;
|
||||
rateLimitPreservedRules: Array<Record<string, unknown>>;
|
||||
testMode: boolean;
|
||||
proxyMode: 'none' | 'global' | 'custom';
|
||||
httpProxy: string;
|
||||
@@ -46,6 +51,11 @@ export interface PlatformWizardForm {
|
||||
modelRpsLimit: string;
|
||||
modelTpmLimit: string;
|
||||
modelConcurrencyLimit: string;
|
||||
modelQueueSize: string;
|
||||
modelQueueMaxWaitSeconds: string;
|
||||
modelRateLimitPolicyExtra: Record<string, unknown>;
|
||||
modelRateLimitRuleExtras: Record<string, Record<string, unknown>>;
|
||||
modelRateLimitPreservedRules: Array<Record<string, unknown>>;
|
||||
selectionMode: 'all' | 'partial';
|
||||
selectedModelIds: string[];
|
||||
}
|
||||
@@ -89,6 +99,11 @@ export function createEmptyPlatformForm(provider = '', defaults?: ProviderConnec
|
||||
rpsLimit: '',
|
||||
tpmLimit: '',
|
||||
concurrencyLimit: '',
|
||||
queueSize: '',
|
||||
queueMaxWaitSeconds: '600',
|
||||
rateLimitPolicyExtra: {},
|
||||
rateLimitRuleExtras: {},
|
||||
rateLimitPreservedRules: [],
|
||||
testMode: false,
|
||||
proxyMode: 'none',
|
||||
httpProxy: '',
|
||||
@@ -106,6 +121,11 @@ export function createEmptyPlatformForm(provider = '', defaults?: ProviderConnec
|
||||
modelRpsLimit: '',
|
||||
modelTpmLimit: '',
|
||||
modelConcurrencyLimit: '',
|
||||
modelQueueSize: '',
|
||||
modelQueueMaxWaitSeconds: '600',
|
||||
modelRateLimitPolicyExtra: {},
|
||||
modelRateLimitRuleExtras: {},
|
||||
modelRateLimitPreservedRules: [],
|
||||
selectionMode: 'partial',
|
||||
selectedModelIds: [],
|
||||
};
|
||||
@@ -194,6 +214,11 @@ export function platformModelPayloads(models: BaseModelCatalogItem[], form: Plat
|
||||
rpsLimit: form.modelRpsLimit,
|
||||
tpmLimit: form.modelTpmLimit,
|
||||
concurrencyLimit: form.modelConcurrencyLimit,
|
||||
queueSize: form.modelQueueSize,
|
||||
queueMaxWaitSeconds: form.modelQueueMaxWaitSeconds,
|
||||
rateLimitPolicyExtra: form.modelRateLimitPolicyExtra,
|
||||
rateLimitRuleExtras: form.modelRateLimitRuleExtras,
|
||||
rateLimitPreservedRules: form.modelRateLimitPreservedRules,
|
||||
}) : undefined,
|
||||
rateLimitPolicyMode: form.modelRateLimitOverrideTouched
|
||||
? (form.modelOverrideRateLimit ? 'override' : 'inherit')
|
||||
@@ -326,6 +351,12 @@ export function validatePlatformForm(form: PlatformWizardForm, selectedCount: nu
|
||||
return '请填写 AccessKey 和 SecretKey,或开启测试模式。';
|
||||
}
|
||||
if (form.proxyMode === 'custom' && !form.httpProxy.trim()) return '请填写自定义 HTTP 代理地址。';
|
||||
const platformQueueError = validateQueueRule(form.queueSize, form.queueMaxWaitSeconds, '平台');
|
||||
if (platformQueueError) return platformQueueError;
|
||||
if (form.modelOverrideRateLimit) {
|
||||
const modelQueueError = validateQueueRule(form.modelQueueSize, form.modelQueueMaxWaitSeconds, '模型');
|
||||
if (modelQueueError) return modelQueueError;
|
||||
}
|
||||
if (selectedCount === 0) return '请至少添加一个模型。';
|
||||
return '';
|
||||
}
|
||||
@@ -388,14 +419,52 @@ function credentialPreviewValue(preview: Record<string, unknown> | undefined, ..
|
||||
return '';
|
||||
}
|
||||
|
||||
function rateLimitPolicyPayload(form: Pick<PlatformWizardForm, 'rpmLimit' | 'rpsLimit' | 'tpmLimit' | 'concurrencyLimit'>) {
|
||||
function rateLimitPolicyPayload(form: Pick<
|
||||
PlatformWizardForm,
|
||||
| 'rpmLimit'
|
||||
| 'rpsLimit'
|
||||
| 'tpmLimit'
|
||||
| 'concurrencyLimit'
|
||||
| 'queueSize'
|
||||
| 'queueMaxWaitSeconds'
|
||||
| 'rateLimitPolicyExtra'
|
||||
| 'rateLimitRuleExtras'
|
||||
| 'rateLimitPreservedRules'
|
||||
>) {
|
||||
const rules = [
|
||||
limitRule('rpm', form.rpmLimit),
|
||||
limitRule('rps', form.rpsLimit, 1),
|
||||
limitRule('tpm_total', form.tpmLimit),
|
||||
limitRule('concurrent', form.concurrencyLimit),
|
||||
...form.rateLimitPreservedRules,
|
||||
limitRule('rpm', form.rpmLimit, 60, form.rateLimitRuleExtras.rpm),
|
||||
limitRule('rps', form.rpsLimit, 1, form.rateLimitRuleExtras.rps),
|
||||
limitRule('tpm_total', form.tpmLimit, 60, form.rateLimitRuleExtras.tpm_total),
|
||||
limitRule('concurrent', form.concurrencyLimit, 60, form.rateLimitRuleExtras.concurrent),
|
||||
queueLimitRule(form.queueSize, form.queueMaxWaitSeconds, form.rateLimitRuleExtras.queue_size),
|
||||
].filter((rule): rule is NonNullable<ReturnType<typeof limitRule>> => Boolean(rule));
|
||||
return { rules };
|
||||
return { ...form.rateLimitPolicyExtra, rules };
|
||||
}
|
||||
|
||||
function queueLimitRule(queueSize: string, maxWaitSeconds: string, extra: Record<string, unknown> = {}) {
|
||||
const limit = Number(queueSize);
|
||||
if (!Number.isFinite(limit) || limit <= 0) return undefined;
|
||||
const parsedMaxWait = Number(maxWaitSeconds);
|
||||
return {
|
||||
...extra,
|
||||
metric: 'queue_size',
|
||||
limit,
|
||||
maxWaitSeconds: Number.isInteger(parsedMaxWait) && parsedMaxWait > 0 ? parsedMaxWait : 600,
|
||||
};
|
||||
}
|
||||
|
||||
function validateQueueRule(queueSize: string, maxWaitSeconds: string, scopeLabel: string) {
|
||||
if (!queueSize.trim() || Number(queueSize) === 0) return '';
|
||||
const size = Number(queueSize);
|
||||
if (!Number.isInteger(size) || size < 1 || size > 10000) {
|
||||
return `${scopeLabel}排队长度必须是 1–10000 的整数,0 或留空表示关闭排队。`;
|
||||
}
|
||||
const wait = maxWaitSeconds.trim() ? Number(maxWaitSeconds) : 600;
|
||||
if (!Number.isInteger(wait) || wait < 1 || wait > 3600) {
|
||||
return `${scopeLabel}最长等待必须是 1–3600 秒的整数。`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function networkProxyPayload(form: PlatformWizardForm) {
|
||||
@@ -408,14 +477,17 @@ function networkProxyPayload(form: PlatformWizardForm) {
|
||||
return { mode: form.proxyMode };
|
||||
}
|
||||
|
||||
function limitRule(metric: string, value: string, windowSeconds = 60) {
|
||||
function limitRule(metric: string, value: string, windowSeconds = 60, extra: Record<string, unknown> = {}) {
|
||||
const limit = positiveNumber(value, 0);
|
||||
if (!limit) return undefined;
|
||||
return {
|
||||
...extra,
|
||||
metric,
|
||||
limit,
|
||||
windowSeconds,
|
||||
leaseTtlSeconds: metric === 'concurrent' ? 120 : 70,
|
||||
windowSeconds: typeof extra.windowSeconds === 'number' ? extra.windowSeconds : windowSeconds,
|
||||
leaseTtlSeconds: typeof extra.leaseTtlSeconds === 'number'
|
||||
? extra.leaseTtlSeconds
|
||||
: (metric === 'concurrent' ? 120 : 70),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -855,7 +855,7 @@
|
||||
}
|
||||
|
||||
.userGroupRateLimitTable .userGroupPolicyRow {
|
||||
grid-template-columns: minmax(170px, 1.15fr) minmax(100px, 0.65fr) minmax(112px, 0.65fr) minmax(120px, 0.72fr) minmax(156px, 0.9fr) 42px;
|
||||
grid-template-columns: minmax(170px, 1.15fr) minmax(100px, 0.65fr) minmax(112px, 0.65fr) minmax(120px, 0.72fr) minmax(120px, 0.72fr) minmax(156px, 0.9fr) 42px;
|
||||
}
|
||||
|
||||
.userGroupQuotaTable .userGroupPolicyRow {
|
||||
|
||||
Reference in New Issue
Block a user