@naced/llm-multi-rate-limiter 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/LICENSE +22 -0
- package/README.md +67 -2
- package/README.zh.md +72 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +691 -0
- package/lib/index.js +454 -0
- package/lib/status-rpc.js +285 -0
- package/lib/strategies/sliding-window.js +148 -0
- package/lib/strategies/token-bucket.js +168 -0
- package/lib/types/config.js +85 -0
- package/lib/types/index.js +1 -0
- package/package.json +59 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
/**
|
|
3
|
+
* dsh-llm-multi-rate-limiter — Client (browser) entry point.
|
|
4
|
+
*
|
|
5
|
+
* Registers the rate limiter's configuration page in the Plugins page's
|
|
6
|
+
* `plugins.item` slot (DSH 0.1.7+). The page draws the card chrome — icon,
|
|
7
|
+
* title, crumb, and the "open" navigation — and asks the entry for two views:
|
|
8
|
+
*
|
|
9
|
+
* - `view: "summary"` → the one-line description on the card;
|
|
10
|
+
* - `view: "page"` → the configuration body plus the live status panel.
|
|
11
|
+
*
|
|
12
|
+
* Configuration values and the write channel come from the page itself
|
|
13
|
+
* (`props.form.state` / `props.form.mutate`), not from a plugin-owned
|
|
14
|
+
* `settingsScope`: `dsh-settings` projects the Host entry's volatile Config
|
|
15
|
+
* fields into one shared form per entry, and the page owns that form.
|
|
16
|
+
*
|
|
17
|
+
* Browser format: CJS-style factory registered via window.__ModuleLoader__.load
|
|
18
|
+
* (loaded as a plain <script>, so NO top-level ESM `export` allowed).
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-llm-multi-rate-limiter/client
|
|
21
|
+
*/
|
|
22
|
+
window.__ModuleLoader__.load({
|
|
23
|
+
id: "@naced/llm-multi-rate-limiter",
|
|
24
|
+
factory: (require) => {
|
|
25
|
+
var module = { exports: {} };
|
|
26
|
+
var exports = module.exports;
|
|
27
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
28
|
+
|
|
29
|
+
// ── React imports (browser module system provides them) ──────────────
|
|
30
|
+
let react, jsx, jsxs;
|
|
31
|
+
react = require("react");
|
|
32
|
+
({ jsx, jsxs } = require("react/jsx-runtime"));
|
|
33
|
+
|
|
34
|
+
// ── Shared constants ──
|
|
35
|
+
/**
|
|
36
|
+
* Settings namespace = the Host profile entry's id (its
|
|
37
|
+
* `cordis.patch.yml` row id). `dsh-settings` keys every form by
|
|
38
|
+
* `entry.options.id`, so this constant must match the row id.
|
|
39
|
+
*/
|
|
40
|
+
const name = "llm-multi-rate-limiter";
|
|
41
|
+
/** Locale dictionary namespace owned by this plugin. */
|
|
42
|
+
const NS = "settings.llmMultiRateLimiter";
|
|
43
|
+
/** Static fiber inject: every service this bundle reads. */
|
|
44
|
+
const inject = ["slots", "locale", "configForms"];
|
|
45
|
+
|
|
46
|
+
/* ── locale dictionaries ─────────────────────────────────────────── */
|
|
47
|
+
const zh = {
|
|
48
|
+
title: "LLM 调用限速",
|
|
49
|
+
description: "按模型限制 LLM 请求频率,超出限制的请求排队等待",
|
|
50
|
+
unavailable: "该插件当前未加载,暂时无法配置。",
|
|
51
|
+
readOnly: "本部署的设置为只读。",
|
|
52
|
+
};
|
|
53
|
+
const en = {
|
|
54
|
+
title: "LLM rate limiter",
|
|
55
|
+
description: "Limit per-model LLM request rates; throttled calls wait in queue",
|
|
56
|
+
unavailable: "This plugin is not loaded, so it cannot be configured right now.",
|
|
57
|
+
readOnly: "This deployment stores settings read-only.",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/* ── CSS-in-JS (design tokens, injected once) ────────────────────── */
|
|
61
|
+
(() => {
|
|
62
|
+
const styles = [
|
|
63
|
+
".rlr-page{display:flex;flex-direction:column;gap:4px;list-style:none;margin:0}",
|
|
64
|
+
".rlr-status{color:var(--dsw-alias-label-tertiary);margin:8px 0 4px;font-size:12px;line-height:1.5}",
|
|
65
|
+
".rlr-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:6px 0}",
|
|
66
|
+
".rlr-label{min-width:140px;font-size:13px;color:var(--dsw-alias-label-secondary)}",
|
|
67
|
+
".rlr-input{width:80px;padding:4px 8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-surface-s2);color:var(--dsw-alias-label-primary);font-size:13px}",
|
|
68
|
+
".rlr-select{padding:4px 8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-surface-s2);color:var(--dsw-alias-label-primary);font-size:13px}",
|
|
69
|
+
".rlr-btn{height:30px;padding:0 12px;border-radius:15px;border:none;cursor:pointer;font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary-foreground);background:var(--dsh-alias-button-primary-fill,#4f46e5)}",
|
|
70
|
+
".rlr-btnSec{height:26px;padding:0 10px;border-radius:13px;border:none;cursor:pointer;font-size:12px;background:var(--dsw-alias-button-secondary-fill);color:var(--dsw-alias-label-primary)}",
|
|
71
|
+
".rlr-danger{height:26px;padding:0 8px;border-radius:13px;border:none;cursor:pointer;font-size:11px;background:var(--dsw-alias-state-error-primary);color:#fff}",
|
|
72
|
+
".rlr-modelRow{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-radius:12px;border:1px solid var(--dsw-alias-border-l4);gap:8px;margin:4px 0}",
|
|
73
|
+
".rlr-modelName{font-size:13px;font-weight:500;font-family:monospace}",
|
|
74
|
+
".rlr-modelMeta{display:flex;gap:12px;font-size:12px;color:var(--dsw-alias-label-tertiary)}",
|
|
75
|
+
".rlr-section{display:flex;flex-direction:column;gap:4px;margin-top:2px}",
|
|
76
|
+
".rlr-sectHead{margin:10px 0 0;font-size:14px;font-weight:500}",
|
|
77
|
+
// ── status panel ──
|
|
78
|
+
".rlr-statusHead{display:flex;align-items:center;gap:8px;margin:10px 0 0}",
|
|
79
|
+
".rlr-statusTitle{font-size:14px;font-weight:500;flex:1}",
|
|
80
|
+
".rlr-chips{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0}",
|
|
81
|
+
".rlr-chip{font-size:11px;padding:2px 8px;border-radius:10px;background:var(--dsw-alias-surface-s2);color:var(--dsw-alias-label-secondary);white-space:nowrap;font-variant-numeric:tabular-nums}",
|
|
82
|
+
".rlr-chipOk{background:var(--dsw-alias-state-success-primary,#16a34a);color:#fff}",
|
|
83
|
+
".rlr-chipWarn{background:var(--dsw-alias-state-warning-primary,#d97706);color:#fff}",
|
|
84
|
+
".rlr-chipErr{background:var(--dsw-alias-state-error-primary,#dc2626);color:#fff}",
|
|
85
|
+
".rlr-phase{font-size:11px;color:var(--dsw-alias-label-tertiary)}",
|
|
86
|
+
".rlr-phaseLive{color:var(--dsw-alias-state-success-primary,#16a34a)}",
|
|
87
|
+
".rlr-phaseFailed{color:var(--dsw-alias-state-error-primary,#dc2626)}",
|
|
88
|
+
".rlr-models{display:flex;flex-direction:column;gap:4px;margin:6px 0}",
|
|
89
|
+
".rlr-mRow{display:flex;align-items:center;gap:10px;padding:6px 10px;border-radius:10px;border:1px solid var(--dsw-alias-border-l4)}",
|
|
90
|
+
".rlr-mName{font-size:12px;font-family:monospace;min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
91
|
+
".rlr-mKind{font-size:10px;padding:1px 6px;border-radius:8px;background:var(--dsw-alias-surface-s2);color:var(--dsw-alias-label-tertiary);white-space:nowrap}",
|
|
92
|
+
".rlr-mMeta{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;font-variant-numeric:tabular-nums}",
|
|
93
|
+
".rlr-mIdle{opacity:.55}",
|
|
94
|
+
".rlr-bar{width:90px;height:6px;border-radius:3px;background:var(--dsw-alias-surface-s2);overflow:hidden;flex:none}",
|
|
95
|
+
".rlr-barFill{height:100%;background:var(--dsw-alias-brand-primary,#4f46e5);transition:width .3s}",
|
|
96
|
+
".rlr-barFillWarn{background:var(--dsw-alias-state-warning-primary,#d97706)}",
|
|
97
|
+
".rlr-log{display:flex;flex-direction:column;gap:1px;margin:6px 0;font-family:monospace;font-size:11px}",
|
|
98
|
+
".rlr-logRow{display:flex;gap:8px;padding:1px 4px;border-radius:4px;color:var(--dsw-alias-label-tertiary)}",
|
|
99
|
+
".rlr-logRowNew{background:var(--dsw-alias-surface-s2)}",
|
|
100
|
+
".rlr-logEv{min-width:66px}",
|
|
101
|
+
".rlr-logEvOk{color:var(--dsw-alias-state-success-primary,#16a34a)}",
|
|
102
|
+
".rlr-logEvWarn{color:var(--dsw-alias-state-warning-primary,#d97706)}",
|
|
103
|
+
".rlr-logEvErr{color:var(--dsw-alias-state-error-primary,#dc2626)}",
|
|
104
|
+
".rlr-logMs{min-width:56px;text-align:right}",
|
|
105
|
+
].join("\n");
|
|
106
|
+
const tagId = "@dsh-llm-multi-rate-limiter/rate-limiter-card.css";
|
|
107
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
108
|
+
const tag = document.createElement("style");
|
|
109
|
+
tag.dataset.plugin = "dsh-llm-multi-rate-limiter";
|
|
110
|
+
tag.dataset.pluginCss = tagId;
|
|
111
|
+
tag.textContent = styles;
|
|
112
|
+
document.head.appendChild(tag);
|
|
113
|
+
}
|
|
114
|
+
})();
|
|
115
|
+
|
|
116
|
+
/* ── helpers ─────────────────────────────────────────────────────── */
|
|
117
|
+
function readNum(cfg, key, field) {
|
|
118
|
+
return cfg.models?.[key]?.[field] ?? cfg.defaults?.[field] ?? 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* ── status channel access (dsh-context defensive idiom) ─────────── */
|
|
122
|
+
const STATUS_CHANNEL = "/llm-multi-rate-limiter";
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The connection's bound generic-RPC caller, or undefined when the
|
|
126
|
+
* service is absent or hostile. Every read is guarded, so this can
|
|
127
|
+
* never throw — a missing channel degrades the panel, not the page.
|
|
128
|
+
*/
|
|
129
|
+
function rpcCallOf(ctx) {
|
|
130
|
+
try {
|
|
131
|
+
const rpc = ctx.get("connection")?.rpc;
|
|
132
|
+
const fn = rpc?.call;
|
|
133
|
+
if (rpc && typeof fn === "function") return fn.bind(rpc);
|
|
134
|
+
} catch {}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Format a duration in ms for the chips/log (compact). */
|
|
139
|
+
function fmtMs(ms) {
|
|
140
|
+
if (!Number.isFinite(ms) || ms <= 0) return "0";
|
|
141
|
+
if (ms < 1000) return Math.round(ms) + "ms";
|
|
142
|
+
if (ms < 60_000) return (ms / 1000).toFixed(1) + "s";
|
|
143
|
+
return Math.round(ms / 60_000) + "m";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Human label for a strategy id. */
|
|
147
|
+
function strategyLabel(id) {
|
|
148
|
+
return id === "sliding-window" ? "滑动窗口" : "令牌桶";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Tunable polling cadence: first retry delay doubles up to 8s. */
|
|
152
|
+
const POLL_MS = 1000;
|
|
153
|
+
const POLL_MAX_MULTIPLIER = 8;
|
|
154
|
+
|
|
155
|
+
/* ── React component: live status panel ──────────────────────────── */
|
|
156
|
+
function MultiRateLimiterStatus({ statusCall, cfg }) {
|
|
157
|
+
const [snap, setSnap] = react.useState(null);
|
|
158
|
+
const [phase, setPhase] = react.useState("connecting");
|
|
159
|
+
const prevRev = react.useRef(-1);
|
|
160
|
+
const [highlight, setHighlight] = react.useState(false);
|
|
161
|
+
|
|
162
|
+
react.useEffect(() => {
|
|
163
|
+
const call = statusCall();
|
|
164
|
+
if (call === undefined) {
|
|
165
|
+
setPhase("no-channel");
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
let stop = false;
|
|
169
|
+
let timer = null;
|
|
170
|
+
let failures = 0;
|
|
171
|
+
|
|
172
|
+
const tick = async () => {
|
|
173
|
+
try {
|
|
174
|
+
const r = await call(STATUS_CHANNEL, "snapshot", {});
|
|
175
|
+
if (stop) return;
|
|
176
|
+
if (r?.ok !== true || !r.value) throw new Error("bad envelope");
|
|
177
|
+
setSnap(r.value);
|
|
178
|
+
setPhase("live");
|
|
179
|
+
failures = 0;
|
|
180
|
+
} catch {
|
|
181
|
+
if (stop) return;
|
|
182
|
+
failures += 1;
|
|
183
|
+
setPhase("failed");
|
|
184
|
+
}
|
|
185
|
+
if (stop) return;
|
|
186
|
+
const multiplier = Math.min(2 ** Math.min(failures, 3), POLL_MAX_MULTIPLIER);
|
|
187
|
+
timer = setTimeout(tick, POLL_MS * multiplier);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
tick();
|
|
191
|
+
return () => {
|
|
192
|
+
stop = true;
|
|
193
|
+
if (timer) clearTimeout(timer);
|
|
194
|
+
};
|
|
195
|
+
}, [statusCall]);
|
|
196
|
+
|
|
197
|
+
// Flash newly arrived events when `rev` advances between polls.
|
|
198
|
+
react.useEffect(() => {
|
|
199
|
+
if (!snap) return undefined;
|
|
200
|
+
if (prevRev.current === -1) { prevRev.current = snap.rev; return undefined; }
|
|
201
|
+
if (snap.rev === prevRev.current) return undefined;
|
|
202
|
+
prevRev.current = snap.rev;
|
|
203
|
+
setHighlight(true);
|
|
204
|
+
const t = setTimeout(() => setHighlight(false), 700);
|
|
205
|
+
return () => clearTimeout(t);
|
|
206
|
+
}, [snap]);
|
|
207
|
+
|
|
208
|
+
// ── non-live phases ──
|
|
209
|
+
if (phase === "no-channel") {
|
|
210
|
+
return jsx("div", { className: "rlr-status", children: "状态通道不可用(需要 DSH web 组合)" });
|
|
211
|
+
}
|
|
212
|
+
if (phase === "connecting" && !snap) {
|
|
213
|
+
return jsx("div", { className: "rlr-status", children: "正在连接状态通道…" });
|
|
214
|
+
}
|
|
215
|
+
if (!snap) {
|
|
216
|
+
return jsx("div", { className: "rlr-status", children: "连接中断,重试中…" });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const t = snap.totals ?? {};
|
|
220
|
+
const avgWait = t.granted > 0 ? (t.totalWaitMs ?? 0) / t.granted : 0;
|
|
221
|
+
const live = phase === "live";
|
|
222
|
+
|
|
223
|
+
function onReset() {
|
|
224
|
+
const call = statusCall();
|
|
225
|
+
if (call === undefined) return;
|
|
226
|
+
Promise.resolve(call(STATUS_CHANNEL, "reset", {})).catch(() => undefined);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Union of configured models and models that have actually been used.
|
|
230
|
+
const snapModels = snap.models ?? {};
|
|
231
|
+
const keys = Array.from(new Set([...Object.keys(cfg.models ?? {}), ...Object.keys(snapModels)]));
|
|
232
|
+
|
|
233
|
+
const chips = [
|
|
234
|
+
{ label: "请求 " + (t.requests ?? 0) },
|
|
235
|
+
{ label: "通过 " + (t.granted ?? 0), tone: "ok" },
|
|
236
|
+
(t.rejected ?? 0) > 0 && { label: "拒绝 " + t.rejected, tone: "err" },
|
|
237
|
+
(t.timeouts ?? 0) > 0 && { label: "超时 " + t.timeouts, tone: "warn" },
|
|
238
|
+
(t.aborted ?? 0) > 0 && { label: "中止 " + t.aborted, tone: "warn" },
|
|
239
|
+
{ label: "平均等待 " + fmtMs(avgWait) },
|
|
240
|
+
].filter(Boolean);
|
|
241
|
+
|
|
242
|
+
const events = Array.isArray(snap.events) ? snap.events.slice().reverse() : [];
|
|
243
|
+
|
|
244
|
+
return jsxs("div", { className: "rlr-section", children: [
|
|
245
|
+
jsxs("div", { className: "rlr-statusHead", children: [
|
|
246
|
+
jsx("span", { className: "rlr-statusTitle", children: "📊 实时状态" }),
|
|
247
|
+
jsx("span", {
|
|
248
|
+
className: "rlr-phase" + (live ? " rlr-phaseLive" : " rlr-phaseFailed"),
|
|
249
|
+
children: live ? "● 实时" : "○ 重试中",
|
|
250
|
+
}),
|
|
251
|
+
jsx("button", { type: "button", className: "rlr-btnSec", onClick: onReset, children: "清零" }),
|
|
252
|
+
] }),
|
|
253
|
+
|
|
254
|
+
snap.enabled === false
|
|
255
|
+
? jsx("div", { className: "rlr-status", children: "限速已停用" })
|
|
256
|
+
: null,
|
|
257
|
+
|
|
258
|
+
jsx("div", { className: "rlr-chips", children: chips.map((c, i) =>
|
|
259
|
+
jsx("span", {
|
|
260
|
+
key: i,
|
|
261
|
+
className: "rlr-chip" + (c.tone === "ok" ? " rlr-chipOk" : c.tone === "warn" ? " rlr-chipWarn" : c.tone === "err" ? " rlr-chipErr" : ""),
|
|
262
|
+
children: c.label,
|
|
263
|
+
}),
|
|
264
|
+
) }),
|
|
265
|
+
|
|
266
|
+
keys.length === 0
|
|
267
|
+
? jsx("div", { className: "rlr-status", children: "尚无模型调用记录" })
|
|
268
|
+
: jsx("div", { className: "rlr-models", children: keys.map((key) => {
|
|
269
|
+
const m = snapModels[key];
|
|
270
|
+
if (!m) {
|
|
271
|
+
return jsxs("div", { key, className: "rlr-mRow rlr-mIdle", children: [
|
|
272
|
+
jsx("span", { className: "rlr-mName", children: key }),
|
|
273
|
+
jsx("span", { className: "rlr-mMeta", children: "未调用" }),
|
|
274
|
+
] });
|
|
275
|
+
}
|
|
276
|
+
const isBucket = m.strategy !== "sliding-window";
|
|
277
|
+
const numerator = isBucket ? (m.tokens ?? 0) : (m.countInWindow ?? 0);
|
|
278
|
+
const denominator = isBucket ? (m.burstSize ?? 0) : (m.maxRpm ?? 0);
|
|
279
|
+
const pct = denominator > 0 ? Math.min(100, Math.round((numerator / denominator) * 100)) : 0;
|
|
280
|
+
// A nearly empty bucket / a nearly full window both mean "close to throttling".
|
|
281
|
+
const nearLimit = isBucket ? pct <= 20 : pct >= 80;
|
|
282
|
+
return jsxs("div", { key, className: "rlr-mRow", children: [
|
|
283
|
+
jsx("span", { className: "rlr-mName", title: key, children: key }),
|
|
284
|
+
jsx("span", { className: "rlr-mKind", children: strategyLabel(m.strategy) }),
|
|
285
|
+
jsx("span", { className: "rlr-bar", children:
|
|
286
|
+
jsx("span", { className: "rlr-barFill" + (nearLimit ? " rlr-barFillWarn" : ""), style: { width: pct + "%" } }),
|
|
287
|
+
}),
|
|
288
|
+
jsx("span", { className: "rlr-mMeta", children:
|
|
289
|
+
(isBucket ? numerator + "/" + denominator : numerator + "/" + denominator + " rpm"),
|
|
290
|
+
}),
|
|
291
|
+
jsx("span", { className: "rlr-mMeta", children: "并发 " + (m.concurrent ?? 0) + "/" + (m.maxConcurrent ?? 0) }),
|
|
292
|
+
(m.queued ?? 0) > 0
|
|
293
|
+
? jsx("span", { className: "rlr-chip rlr-chipWarn", children: "排队 " + m.queued })
|
|
294
|
+
: null,
|
|
295
|
+
] });
|
|
296
|
+
}) }),
|
|
297
|
+
|
|
298
|
+
events.length > 0
|
|
299
|
+
? jsx("div", { className: "rlr-log", children: events.map((e, i) => {
|
|
300
|
+
const good = e.event === "granted" || e.event === "completed";
|
|
301
|
+
const bad = e.event === "rejected";
|
|
302
|
+
const d = new Date(e.ts ?? Date.now());
|
|
303
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
304
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
305
|
+
const ss = String(d.getSeconds()).padStart(2, "0");
|
|
306
|
+
return jsxs("div", {
|
|
307
|
+
className: "rlr-logRow" + (highlight && i === 0 ? " rlr-logRowNew" : ""),
|
|
308
|
+
children: [
|
|
309
|
+
jsx("span", { children: hh + ":" + mm + ":" + ss }),
|
|
310
|
+
jsx("span", { className: "rlr-logEv " + (good ? "rlr-logEvOk" : bad ? "rlr-logEvErr" : "rlr-logEvWarn"), children: e.event }),
|
|
311
|
+
jsx("span", { className: "rlr-mName", title: e.model, children: e.model }),
|
|
312
|
+
(e.waitMs ?? 0) > 0
|
|
313
|
+
? jsx("span", { className: "rlr-logMs", children: "等待 " + fmtMs(e.waitMs) })
|
|
314
|
+
: null,
|
|
315
|
+
],
|
|
316
|
+
});
|
|
317
|
+
}) })
|
|
318
|
+
: null,
|
|
319
|
+
] });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* ── React component: the Settings section ────────────────────────── */
|
|
323
|
+
/**
|
|
324
|
+
* One number field bound to a config path. Kept dumb on purpose: the
|
|
325
|
+
* Host validates, this only forwards the edit as one atomic op.
|
|
326
|
+
*/
|
|
327
|
+
function NumField(props) {
|
|
328
|
+
const { label, value, placeholder, min, step, disabled, onChange } = props;
|
|
329
|
+
return jsxs("div", { className: "rlr-row", children: [
|
|
330
|
+
jsx("span", { className: "rlr-label", children: label }),
|
|
331
|
+
jsx("input", {
|
|
332
|
+
className: "rlr-input",
|
|
333
|
+
type: "number",
|
|
334
|
+
min,
|
|
335
|
+
step,
|
|
336
|
+
disabled,
|
|
337
|
+
value: value === undefined || value === null ? "" : value,
|
|
338
|
+
placeholder,
|
|
339
|
+
onChange: (e) => onChange(e.target.value),
|
|
340
|
+
}),
|
|
341
|
+
] });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The section body: configuration form (Host settings document) plus the
|
|
346
|
+
* live status panel. Values come from `props.form`, the
|
|
347
|
+
* `ConfigFormController` this plugin owns for its own namespace, so every
|
|
348
|
+
* edit is one atomic `mutate(ops)` on `settings.mutate`.
|
|
349
|
+
*
|
|
350
|
+
* @param props - `t`, the owned `form`, the framework's `useSnapshot`
|
|
351
|
+
* selector hook (bound to `form.store`), and `statusCall`.
|
|
352
|
+
*/
|
|
353
|
+
function SectionPage(props) {
|
|
354
|
+
const t = typeof props.t === "function" ? props.t : (key) => key;
|
|
355
|
+
const form = props.form;
|
|
356
|
+
const snap = typeof props.useSnapshot === "function"
|
|
357
|
+
? props.useSnapshot((s) => s)
|
|
358
|
+
: form?.getSnapshot?.();
|
|
359
|
+
const statusCall = props.statusCall;
|
|
360
|
+
|
|
361
|
+
const cfg = snap !== undefined && snap.status === "ready" && snap.value ? snap.value : {};
|
|
362
|
+
const writable = snap === undefined ? true : snap.writable !== false;
|
|
363
|
+
const [draftKey, setDraftKey] = react.useState("");
|
|
364
|
+
|
|
365
|
+
/** Queue one atomic namespace mutation; failures leave the form as-is. */
|
|
366
|
+
function push(ops) {
|
|
367
|
+
if (!form || typeof form.mutate !== "function") return;
|
|
368
|
+
try {
|
|
369
|
+
Promise.resolve(form.mutate(ops)).catch(() => undefined);
|
|
370
|
+
} catch {}
|
|
371
|
+
}
|
|
372
|
+
const setPath = (path, value) => push([{ op: "set", path, value }]);
|
|
373
|
+
const unsetPath = (path) => push([{ op: "unset", path }]);
|
|
374
|
+
const setTop = (field, value) => setPath([field], value);
|
|
375
|
+
const setDefault = (field, value) => setPath(["defaults", field], value);
|
|
376
|
+
/** Empty input clears the override, restoring inheritance. */
|
|
377
|
+
function setModel(key, field, raw) {
|
|
378
|
+
const path = ["models", key, field];
|
|
379
|
+
if (raw === "") unsetPath(path);
|
|
380
|
+
else {
|
|
381
|
+
const n = Number(raw);
|
|
382
|
+
if (!Number.isFinite(n)) return;
|
|
383
|
+
setPath(path, n);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const modelKeys = Object.keys(cfg.models ?? {}).sort();
|
|
388
|
+
const strategy = cfg.strategy ?? "token-bucket";
|
|
389
|
+
const bucket = strategy !== "sliding-window";
|
|
390
|
+
const defaults = cfg.defaults ?? {};
|
|
391
|
+
|
|
392
|
+
function addModel() {
|
|
393
|
+
const key = draftKey.trim();
|
|
394
|
+
if (!key || !/^[^/\s]+\/.+$/.test(key) || modelKeys.includes(key)) {
|
|
395
|
+
setDraftKey("");
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
setPath(["models", key], {});
|
|
399
|
+
setDraftKey("");
|
|
400
|
+
}
|
|
401
|
+
function removeModel(key) {
|
|
402
|
+
unsetPath(["models", key]);
|
|
403
|
+
}
|
|
404
|
+
function toggleModel(key) {
|
|
405
|
+
const off = cfg.models?.[key]?.enabled === false;
|
|
406
|
+
if (off) unsetPath(["models", key, "enabled"]);
|
|
407
|
+
else setPath(["models", key, "enabled"], false);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return jsxs("div", { className: "rlr-page", children: [
|
|
411
|
+
/* ── unavailable / read-only notices ── */
|
|
412
|
+
snap?.status === "unavailable"
|
|
413
|
+
? jsx("div", { className: "rlr-status", children: t("unavailable") })
|
|
414
|
+
: null,
|
|
415
|
+
snap !== undefined && !writable
|
|
416
|
+
? jsx("div", { className: "rlr-status", children: t("readOnly") })
|
|
417
|
+
: null,
|
|
418
|
+
|
|
419
|
+
/* ── 实时状态面板 ── */
|
|
420
|
+
jsx(MultiRateLimiterStatus, { statusCall, cfg }),
|
|
421
|
+
|
|
422
|
+
/* ── 总开关 ── */
|
|
423
|
+
jsx("div", { className: "rlr-section", children: [
|
|
424
|
+
jsx("div", { className: "rlr-sectHead", children: t("title") }),
|
|
425
|
+
jsx("div", { className: "rlr-status", children: t("description") }),
|
|
426
|
+
jsxs("div", { className: "rlr-row", children: [
|
|
427
|
+
jsxs("label", { style: { display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }, children: [
|
|
428
|
+
jsx("input", {
|
|
429
|
+
type: "checkbox",
|
|
430
|
+
checked: cfg.enabled ?? true,
|
|
431
|
+
disabled: !writable,
|
|
432
|
+
onChange: (e) => setTop("enabled", e.target.checked),
|
|
433
|
+
}),
|
|
434
|
+
t("enabled"),
|
|
435
|
+
] }),
|
|
436
|
+
] }),
|
|
437
|
+
] }),
|
|
438
|
+
|
|
439
|
+
/* ── 默认配置 ── */
|
|
440
|
+
jsxs("div", { className: "rlr-section", children: [
|
|
441
|
+
jsx("div", { className: "rlr-sectHead", children: t("defaults") }),
|
|
442
|
+
jsxs("div", { className: "rlr-row", children: [
|
|
443
|
+
jsx("span", { className: "rlr-label", children: t("strategy") }),
|
|
444
|
+
jsxs("select", {
|
|
445
|
+
className: "rlr-select",
|
|
446
|
+
value: strategy,
|
|
447
|
+
disabled: !writable,
|
|
448
|
+
onChange: (e) => setTop("strategy", e.target.value),
|
|
449
|
+
children: [
|
|
450
|
+
jsx("option", { value: "token-bucket", children: t("tokenBucket") }),
|
|
451
|
+
jsx("option", { value: "sliding-window", children: t("slidingWindow") }),
|
|
452
|
+
],
|
|
453
|
+
}),
|
|
454
|
+
] }),
|
|
455
|
+
jsx(NumField, { label: t("maxConcurrent"), value: defaults.maxConcurrent, placeholder: "5", min: 1, step: 1, disabled: !writable, onChange: (v) => setDefault("maxConcurrent", Number(v) || 1) }),
|
|
456
|
+
jsx(NumField, { label: t("maxRpm"), value: defaults.maxRpm, placeholder: "60", min: 1, step: 1, disabled: !writable, onChange: (v) => setDefault("maxRpm", Number(v) || 1) }),
|
|
457
|
+
bucket
|
|
458
|
+
? jsx(NumField, { label: t("burstSize"), value: defaults.burstSize, placeholder: "10", min: 1, step: 1, disabled: !writable, onChange: (v) => setDefault("burstSize", Number(v) || 1) })
|
|
459
|
+
: null,
|
|
460
|
+
bucket
|
|
461
|
+
? jsx(NumField, { label: t("refillRate"), value: defaults.refillRate, placeholder: "1", min: 0.1, step: 0.1, disabled: !writable, onChange: (v) => setDefault("refillRate", Number(v) || 0.1) })
|
|
462
|
+
: null,
|
|
463
|
+
] }),
|
|
464
|
+
|
|
465
|
+
/* ── 模型专属配置 ── */
|
|
466
|
+
jsxs("div", { className: "rlr-section", children: [
|
|
467
|
+
jsx("div", { className: "rlr-sectHead", children: t("models") }),
|
|
468
|
+
jsx("div", { className: "rlr-status", children: t("modelsHint") }),
|
|
469
|
+
modelKeys.length === 0
|
|
470
|
+
? jsx("p", { className: "rlr-status", children: t("noModels") })
|
|
471
|
+
: modelKeys.map((key) => {
|
|
472
|
+
const o = cfg.models?.[key] ?? {};
|
|
473
|
+
const off = o.enabled === false;
|
|
474
|
+
const effRpm = o.maxRpm ?? defaults.maxRpm ?? 60;
|
|
475
|
+
const effConc = o.maxConcurrent ?? defaults.maxConcurrent ?? 5;
|
|
476
|
+
return jsxs("div", { className: "rlr-modelRow", key, style: off ? { opacity: 0.55 } : undefined, children: [
|
|
477
|
+
jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 2, flex: 1, minWidth: 0 }, children: [
|
|
478
|
+
jsx("span", { className: "rlr-modelName", children: key }),
|
|
479
|
+
jsxs("span", { className: "rlr-modelMeta", children: [
|
|
480
|
+
jsx("span", { children: t("effective") + ": " + effRpm + " rpm / " + effConc + " " + t("concurrent") }),
|
|
481
|
+
] }),
|
|
482
|
+
] }),
|
|
483
|
+
jsxs("div", { style: { display: "flex", alignItems: "center", gap: 6 }, children: [
|
|
484
|
+
jsx("input", {
|
|
485
|
+
className: "rlr-input", type: "number", min: 1, style: { width: 64 }, title: t("maxRpm"),
|
|
486
|
+
disabled: !writable, value: o.maxRpm ?? "", placeholder: "rpm",
|
|
487
|
+
onChange: (e) => setModel(key, "maxRpm", e.target.value),
|
|
488
|
+
}),
|
|
489
|
+
jsx("input", {
|
|
490
|
+
className: "rlr-input", type: "number", min: 1, style: { width: 56 }, title: t("maxConcurrent"),
|
|
491
|
+
disabled: !writable, value: o.maxConcurrent ?? "", placeholder: t("concurrent"),
|
|
492
|
+
onChange: (e) => setModel(key, "maxConcurrent", e.target.value),
|
|
493
|
+
}),
|
|
494
|
+
bucket
|
|
495
|
+
? jsx("input", {
|
|
496
|
+
className: "rlr-input", type: "number", min: 1, style: { width: 56 }, title: t("burstSize"),
|
|
497
|
+
disabled: !writable, value: o.burstSize ?? "", placeholder: t("burstSize"),
|
|
498
|
+
onChange: (e) => setModel(key, "burstSize", e.target.value),
|
|
499
|
+
})
|
|
500
|
+
: null,
|
|
501
|
+
jsx("button", { className: "rlr-btnSec", disabled: !writable, onClick: () => toggleModel(key), children: off ? t("enable") : t("disable") }),
|
|
502
|
+
jsx("button", { className: "rlr-danger", disabled: !writable, onClick: () => removeModel(key), title: t("remove"), children: "✕" }),
|
|
503
|
+
] }),
|
|
504
|
+
] });
|
|
505
|
+
}),
|
|
506
|
+
jsxs("div", { className: "rlr-row", children: [
|
|
507
|
+
jsx("input", {
|
|
508
|
+
className: "rlr-input",
|
|
509
|
+
style: { width: 240 },
|
|
510
|
+
placeholder: t("addPlaceholder"),
|
|
511
|
+
value: draftKey,
|
|
512
|
+
disabled: !writable,
|
|
513
|
+
onChange: (e) => setDraftKey(e.target.value),
|
|
514
|
+
onKeyDown: (e) => { if (e.key === "Enter") addModel(); },
|
|
515
|
+
}),
|
|
516
|
+
jsx("button", { className: "rlr-btn", disabled: !writable, onClick: addModel, children: t("addModel") }),
|
|
517
|
+
] }),
|
|
518
|
+
] }),
|
|
519
|
+
|
|
520
|
+
/* ── 被限速时的行为 ── */
|
|
521
|
+
jsxs("div", { className: "rlr-section", children: [
|
|
522
|
+
jsx("div", { className: "rlr-sectHead", children: t("behavior") }),
|
|
523
|
+
jsxs("div", { className: "rlr-row", children: [
|
|
524
|
+
jsxs("label", { style: { display: "flex", alignItems: "center", gap: 4, cursor: "pointer" }, children: [
|
|
525
|
+
jsx("input", {
|
|
526
|
+
type: "radio", name: "mlr-on-throttle", disabled: !writable,
|
|
527
|
+
checked: cfg.onThrottled !== "reject",
|
|
528
|
+
onChange: () => setTop("onThrottled", "queue"),
|
|
529
|
+
}),
|
|
530
|
+
t("queue"),
|
|
531
|
+
] }),
|
|
532
|
+
jsxs("label", { style: { display: "flex", alignItems: "center", gap: 4, cursor: "pointer" }, children: [
|
|
533
|
+
jsx("input", {
|
|
534
|
+
type: "radio", name: "mlr-on-throttle", disabled: !writable,
|
|
535
|
+
checked: cfg.onThrottled === "reject",
|
|
536
|
+
onChange: () => setTop("onThrottled", "reject"),
|
|
537
|
+
}),
|
|
538
|
+
t("reject"),
|
|
539
|
+
] }),
|
|
540
|
+
] }),
|
|
541
|
+
cfg.onThrottled !== "reject"
|
|
542
|
+
? jsx(NumField, { label: t("maxQueueWait"), value: cfg.maxQueueWaitMs, placeholder: "60000", min: 1000, step: 1000, disabled: !writable, onChange: (v) => setTop("maxQueueWaitMs", Number(v) || 60000) })
|
|
543
|
+
: null,
|
|
544
|
+
] }),
|
|
545
|
+
|
|
546
|
+
/* ── 落盘说明 ── */
|
|
547
|
+
jsx("div", { className: "rlr-status", children: t("saveHint") }),
|
|
548
|
+
] });
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/* ── Sidebar icon ─────────────────────────────────────────────────── */
|
|
552
|
+
/**
|
|
553
|
+
* Icon for the root sidebar panel list: a gauge with a needle, which reads
|
|
554
|
+
* as "rate / throttle" at 16px and stays legible in the collapsed rail.
|
|
555
|
+
*
|
|
556
|
+
* @param props - slot props (`size` is supplied by the sidebar row).
|
|
557
|
+
*/
|
|
558
|
+
function PanelIcon(props) {
|
|
559
|
+
const size = typeof props.size === "number" ? props.size : 16;
|
|
560
|
+
return jsx("svg", {
|
|
561
|
+
width: size,
|
|
562
|
+
height: size,
|
|
563
|
+
viewBox: "0 0 24 24",
|
|
564
|
+
fill: "none",
|
|
565
|
+
stroke: "currentColor",
|
|
566
|
+
strokeWidth: 1.7,
|
|
567
|
+
strokeLinecap: "round",
|
|
568
|
+
strokeLinejoin: "round",
|
|
569
|
+
"aria-hidden": "true",
|
|
570
|
+
children: [
|
|
571
|
+
jsx("path", { d: "M4.2 18a8.5 8.5 0 1 1 15.6 0" }),
|
|
572
|
+
jsx("path", { d: "M12 13.4 16.4 9" }),
|
|
573
|
+
jsx("circle", { cx: 12, cy: 14, r: 1.5 }),
|
|
574
|
+
jsx("path", { d: "M3 21h18" }),
|
|
575
|
+
],
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/* ── Plugin registration ──────────────────────────────────────────── */
|
|
580
|
+
/**
|
|
581
|
+
* Register the Settings section, the dictionaries it reads, and the form
|
|
582
|
+
* it edits. The section lives in the Settings navigation, so it does not
|
|
583
|
+
* depend on any other page owning a slot this plugin could not reach.
|
|
584
|
+
*
|
|
585
|
+
* @param ctx - the browser plugin context.
|
|
586
|
+
*/
|
|
587
|
+
function apply(ctx) {
|
|
588
|
+
const t = ctx.locale.bind(NS);
|
|
589
|
+
ctx.effect(() => ctx.locale.register(NS, {
|
|
590
|
+
zh: Object.assign({}, zh, {
|
|
591
|
+
nav: "多模型限速",
|
|
592
|
+
enabled: "启用限速",
|
|
593
|
+
defaults: "默认配置",
|
|
594
|
+
strategy: "策略",
|
|
595
|
+
tokenBucket: "令牌桶 (Token Bucket)",
|
|
596
|
+
slidingWindow: "滑动窗口 (Sliding Window)",
|
|
597
|
+
maxConcurrent: "最大并发数",
|
|
598
|
+
maxRpm: "每分钟最大请求数",
|
|
599
|
+
burstSize: "突发容量",
|
|
600
|
+
refillRate: "补充速率 (个/秒)",
|
|
601
|
+
models: "模型专属配置",
|
|
602
|
+
modelsHint: "格式 provider/model;留空即继承默认值。",
|
|
603
|
+
noModels: "未配置任何模型专属限速,所有模型使用默认值。",
|
|
604
|
+
addModel: "+ 添加模型",
|
|
605
|
+
addPlaceholder: "例如 openai/gpt-5.1",
|
|
606
|
+
effective: "实际生效",
|
|
607
|
+
concurrent: "并发",
|
|
608
|
+
enable: "启用",
|
|
609
|
+
disable: "禁用",
|
|
610
|
+
remove: "删除",
|
|
611
|
+
behavior: "被限速时的行为",
|
|
612
|
+
queue: "排队等待",
|
|
613
|
+
reject: "拒绝请求",
|
|
614
|
+
maxQueueWait: "最大排队等待 (ms)",
|
|
615
|
+
saveHint: "改动即写入 profile 的 cordis.patch.yml,无需重启。",
|
|
616
|
+
}),
|
|
617
|
+
en: Object.assign({}, en, {
|
|
618
|
+
nav: "Multi-model limits",
|
|
619
|
+
enabled: "Rate limiting enabled",
|
|
620
|
+
defaults: "Defaults",
|
|
621
|
+
strategy: "Strategy",
|
|
622
|
+
tokenBucket: "Token bucket",
|
|
623
|
+
slidingWindow: "Sliding window",
|
|
624
|
+
maxConcurrent: "Max concurrent",
|
|
625
|
+
maxRpm: "Max requests / minute",
|
|
626
|
+
burstSize: "Burst size",
|
|
627
|
+
refillRate: "Refill rate (per second)",
|
|
628
|
+
models: "Per-model limits",
|
|
629
|
+
modelsHint: "Format provider/model; leave a field empty to inherit the default.",
|
|
630
|
+
noModels: "No per-model limit yet — every model uses the defaults.",
|
|
631
|
+
addModel: "+ Add model",
|
|
632
|
+
addPlaceholder: "e.g. openai/gpt-5.1",
|
|
633
|
+
effective: "Effective",
|
|
634
|
+
concurrent: "concurrent",
|
|
635
|
+
enable: "Enable",
|
|
636
|
+
disable: "Disable",
|
|
637
|
+
remove: "Remove",
|
|
638
|
+
behavior: "When throttled",
|
|
639
|
+
queue: "Wait in queue",
|
|
640
|
+
reject: "Reject the request",
|
|
641
|
+
maxQueueWait: "Max queue wait (ms)",
|
|
642
|
+
saveHint: "Edits are written to the profile's cordis.patch.yml immediately.",
|
|
643
|
+
}),
|
|
644
|
+
}), "llm-multi-rate-limiter: dictionaries");
|
|
645
|
+
|
|
646
|
+
// The page edits this plugin's own namespace: `get` creates the one
|
|
647
|
+
// shared form for it and disposes with this fiber, so the page needs no
|
|
648
|
+
// `whileServed` gate (it owns the entry).
|
|
649
|
+
const form = ctx.configForms.get(name);
|
|
650
|
+
const inject = () => ({
|
|
651
|
+
form,
|
|
652
|
+
t,
|
|
653
|
+
hooks: { snapshot: form.store },
|
|
654
|
+
statusCall: () => rpcCallOf(ctx),
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// Two surfaces, one page: the Settings section (native settings nav) and
|
|
658
|
+
// a root sidebar panel (icon + label list, `main` keyed by the same id)
|
|
659
|
+
// so the limiter is reachable without opening Settings at all.
|
|
660
|
+
ctx.effect(() => ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
661
|
+
name: "settings.section",
|
|
662
|
+
id: name,
|
|
663
|
+
order: 15,
|
|
664
|
+
label: () => t("nav"),
|
|
665
|
+
locale: NS,
|
|
666
|
+
inject,
|
|
667
|
+
}, SectionPage)), "llm-multi-rate-limiter: settings section");
|
|
668
|
+
|
|
669
|
+
ctx.effect(() => ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
|
|
670
|
+
name: "sidebar.panellist",
|
|
671
|
+
id: name,
|
|
672
|
+
order: 40,
|
|
673
|
+
label: () => t("nav"),
|
|
674
|
+
locale: NS,
|
|
675
|
+
}, PanelIcon)), "llm-multi-rate-limiter: sidebar entry");
|
|
676
|
+
|
|
677
|
+
ctx.effect(() => ctx.slots.inject("main", () => ctx.slots.register({
|
|
678
|
+
name: "main",
|
|
679
|
+
key: name,
|
|
680
|
+
locale: NS,
|
|
681
|
+
inject,
|
|
682
|
+
}, SectionPage)), "llm-multi-rate-limiter: sidebar panel");
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
exports.apply = apply;
|
|
686
|
+
exports.inject = inject;
|
|
687
|
+
exports.name = name;
|
|
688
|
+
exports.NS = NS;
|
|
689
|
+
return module.exports;
|
|
690
|
+
},
|
|
691
|
+
});
|