@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/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
+ });