feat(queue): 增加非文本模型分布式准入队列

使用 PostgreSQL 统一同步与异步非文本任务的并发准入、持久化等待和 Worker 容量分配,并将生产 API 与独立 Worker 角色拆分。

补充策略管理、共享契约、OpenAPI、Kubernetes 双节点 Worker 清单及跨节点验收脚本;未默认启用任何生产 queue_size 策略。

已在原基线完成 Go、前端、迁移、Shell、Kustomize 与长任务容量验收;合入最新主干后将重新执行发布门禁。
This commit is contained in:
2026-07-29 16:15:43 +08:00
parent 2e5a90731b
commit 9e4fc7362d
55 changed files with 4565 additions and 237 deletions
@@ -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;
}
+21 -6
View File
@@ -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('13600');
});
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) };
+75 -1
View File
@@ -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('110000');
expect(validatePlatformForm({ ...form, queueSize: '2', queueMaxWaitSeconds: '3601' }, 1)).toContain('13600');
});
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,
},
],
});
});
});
+81 -9
View File
@@ -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),
};
}
+1 -1
View File
@@ -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 {