@naced/llm-multi-rate-limiter 0.0.0-stage → 0.1.2

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,956 @@
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
+ const [draftProvider, setDraftProvider] = react.useState("");
365
+ const [query, setQuery] = react.useState("");
366
+ const [confirmKey, setConfirmKey] = react.useState("");
367
+ const [importError, setImportError] = react.useState("");
368
+ const fileInputRef = react.useRef(null);
369
+
370
+ /** Queue one atomic namespace mutation; failures leave the form as-is. */
371
+ function push(ops) {
372
+ if (!form || typeof form.mutate !== "function") return;
373
+ try {
374
+ Promise.resolve(form.mutate(ops)).catch(() => undefined);
375
+ } catch {}
376
+ }
377
+ const setPath = (path, value) => push([{ op: "set", path, value }]);
378
+ const unsetPath = (path) => push([{ op: "unset", path }]);
379
+ const setTop = (field, value) => setPath([field], value);
380
+ const setDefault = (field, value) => setPath(["defaults", field], value);
381
+ /** Empty input clears the override, restoring inheritance. */
382
+ function setModel(key, field, raw) {
383
+ const path = ["models", key, field];
384
+ if (raw === "") unsetPath(path);
385
+ else {
386
+ const n = Number(raw);
387
+ if (!Number.isFinite(n)) return;
388
+ setPath(path, n);
389
+ }
390
+ }
391
+ /** Provider tier: same shape, one slashless key. */
392
+ function setProvider(key, field, raw) {
393
+ const path = ["providers", key, field];
394
+ if (raw === "") unsetPath(path);
395
+ else {
396
+ const n = Number(raw);
397
+ if (!Number.isFinite(n)) return;
398
+ setPath(path, n);
399
+ }
400
+ }
401
+ function addProvider() {
402
+ const key = draftProvider.trim();
403
+ if (!key || /[\s/]/.test(key) || providerKeys.includes(key)) {
404
+ setDraftProvider("");
405
+ return;
406
+ }
407
+ setPath(["providers", key], {});
408
+ setDraftProvider("");
409
+ }
410
+ function removeProvider(key) {
411
+ unsetPath(["providers", key]);
412
+ }
413
+ const setCooldown = (field, value) => setPath(["cooldown", field], value);
414
+ /** Downloads the whole namespace value so it can be re-imported. */
415
+ function exportConfig() {
416
+ const payload = {
417
+ $schema: "llm-multi-rate-limiter/config@1",
418
+ exportedAt: new Date().toISOString(),
419
+ config: cfg,
420
+ };
421
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
422
+ const url = URL.createObjectURL(blob);
423
+ const a = document.createElement("a");
424
+ a.href = url;
425
+ a.download = "llm-multi-rate-limiter.json";
426
+ document.body.appendChild(a);
427
+ a.click();
428
+ setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 0);
429
+ setImportError("");
430
+ }
431
+ /** Applies an imported document as one atomic namespace mutation. */
432
+ function applyImport(text) {
433
+ let parsed;
434
+ try {
435
+ parsed = JSON.parse(text);
436
+ } catch {
437
+ setImportError(t("importInvalidJson"));
438
+ return;
439
+ }
440
+ const imported = parsed !== null && typeof parsed === "object"
441
+ && parsed.config !== null && typeof parsed.config === "object"
442
+ ? parsed.config
443
+ : parsed;
444
+ if (imported === null || typeof imported !== "object" || Array.isArray(imported)) {
445
+ setImportError(t("importInvalidShape"));
446
+ return;
447
+ }
448
+ if (!writable) {
449
+ setImportError(t("importReadOnly"));
450
+ return;
451
+ }
452
+ const ops = [];
453
+ for (const field of ["enabled", "strategy", "defaults", "providers", "models", "cooldown", "onThrottled", "maxQueueWaitMs"]) {
454
+ if (Object.prototype.hasOwnProperty.call(imported, field)) {
455
+ ops.push({ op: "set", path: [field], value: imported[field] });
456
+ }
457
+ }
458
+ if (ops.length === 0) {
459
+ setImportError(t("importEmpty"));
460
+ return;
461
+ }
462
+ setImportError("");
463
+ push(ops);
464
+ }
465
+ async function onPickFile(event) {
466
+ const file = event.target.files && event.target.files[0];
467
+ event.target.value = "";
468
+ if (!file) return;
469
+ try {
470
+ applyImport(await file.text());
471
+ } catch {
472
+ setImportError(t("importUnreadable"));
473
+ }
474
+ }
475
+
476
+ const providerKeys = Object.keys(cfg.providers ?? {}).sort();
477
+ const allModelKeys = Object.keys(cfg.models ?? {}).sort();
478
+ const needle = query.trim().toLowerCase();
479
+ // The filter is presentation only: it never hides a stored limit from
480
+ // `mutate`, it just narrows what this render lists.
481
+ const modelKeys = needle === ""
482
+ ? allModelKeys
483
+ : allModelKeys.filter((key) => key.toLowerCase().includes(needle));
484
+ const cooldown = cfg.cooldown ?? {};
485
+ const strategy = cfg.strategy ?? "token-bucket";
486
+ const bucket = strategy !== "sliding-window";
487
+ const defaults = cfg.defaults ?? {};
488
+
489
+ function addModel() {
490
+ const key = draftKey.trim();
491
+ if (!key || !/^[^/\s]+\/.+$/.test(key) || allModelKeys.includes(key)) {
492
+ setDraftKey("");
493
+ return;
494
+ }
495
+ setPath(["models", key], {});
496
+ setDraftKey("");
497
+ }
498
+ function removeModel(key) {
499
+ unsetPath(["models", key]);
500
+ }
501
+ function toggleModel(key) {
502
+ const off = cfg.models?.[key]?.enabled === false;
503
+ if (off) unsetPath(["models", key, "enabled"]);
504
+ else setPath(["models", key, "enabled"], false);
505
+ }
506
+
507
+ return jsxs("div", { className: "rlr-page", children: [
508
+ /* ── unavailable / read-only notices ── */
509
+ snap?.status === "unavailable"
510
+ ? jsx("div", { className: "rlr-status", children: t("unavailable") })
511
+ : null,
512
+ snap !== undefined && !writable
513
+ ? jsx("div", { className: "rlr-status", children: t("readOnly") })
514
+ : null,
515
+
516
+ /* ── 实时状态面板 ── */
517
+ jsx(MultiRateLimiterStatus, { statusCall, cfg }),
518
+
519
+ /* ── 总开关 ── */
520
+ jsx("div", { className: "rlr-section", children: [
521
+ jsx("div", { className: "rlr-sectHead", children: t("title") }),
522
+ jsx("div", { className: "rlr-status", children: t("description") }),
523
+ jsxs("div", { className: "rlr-row", children: [
524
+ jsxs("label", { style: { display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }, children: [
525
+ jsx("input", {
526
+ type: "checkbox",
527
+ checked: cfg.enabled ?? true,
528
+ disabled: !writable,
529
+ onChange: (e) => setTop("enabled", e.target.checked),
530
+ }),
531
+ t("enabled"),
532
+ ] }),
533
+ ] }),
534
+ ] }),
535
+
536
+ /* ── 默认配置 ── */
537
+ jsxs("div", { className: "rlr-section", children: [
538
+ jsx("div", { className: "rlr-sectHead", children: t("defaults") }),
539
+ jsxs("div", { className: "rlr-row", children: [
540
+ jsx("span", { className: "rlr-label", children: t("strategy") }),
541
+ jsxs("select", {
542
+ className: "rlr-select",
543
+ value: strategy,
544
+ disabled: !writable,
545
+ onChange: (e) => setTop("strategy", e.target.value),
546
+ children: [
547
+ jsx("option", { value: "token-bucket", children: t("tokenBucket") }),
548
+ jsx("option", { value: "sliding-window", children: t("slidingWindow") }),
549
+ ],
550
+ }),
551
+ ] }),
552
+ jsx(NumField, { label: t("maxConcurrent"), value: defaults.maxConcurrent, placeholder: "5", min: 1, step: 1, disabled: !writable, onChange: (v) => setDefault("maxConcurrent", Number(v) || 1) }),
553
+ jsx(NumField, { label: t("maxRpm"), value: defaults.maxRpm, placeholder: "60", min: 1, step: 1, disabled: !writable, onChange: (v) => setDefault("maxRpm", Number(v) || 1) }),
554
+ bucket
555
+ ? jsx(NumField, { label: t("burstSize"), value: defaults.burstSize, placeholder: "10", min: 1, step: 1, disabled: !writable, onChange: (v) => setDefault("burstSize", Number(v) || 1) })
556
+ : null,
557
+ bucket
558
+ ? 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) })
559
+ : null,
560
+ ] }),
561
+
562
+ /* ── 模型专属配置 ── */
563
+ jsxs("div", { className: "rlr-section", children: [
564
+ jsx("div", { className: "rlr-sectHead", children: t("models") }),
565
+ jsx("div", { className: "rlr-status", children: t("modelsHint") }),
566
+ allModelKeys.length > 6
567
+ ? jsxs("div", { className: "rlr-row", children: [
568
+ jsx("input", {
569
+ className: "rlr-input",
570
+ style: { width: 240 },
571
+ type: "search",
572
+ placeholder: t("searchPlaceholder"),
573
+ value: query,
574
+ onChange: (e) => setQuery(e.target.value),
575
+ }),
576
+ jsx("span", { className: "rlr-modelMeta", children: modelKeys.length + " / " + allModelKeys.length }),
577
+ ] })
578
+ : null,
579
+ allModelKeys.length === 0
580
+ ? jsx("p", { className: "rlr-status", children: t("noModels") })
581
+ : modelKeys.length === 0
582
+ ? jsx("p", { className: "rlr-status", children: t("noMatches") })
583
+ : modelKeys.map((key) => {
584
+ const o = cfg.models?.[key] ?? {};
585
+ const off = o.enabled === false;
586
+ const effRpm = o.maxRpm ?? defaults.maxRpm ?? 60;
587
+ const effConc = o.maxConcurrent ?? defaults.maxConcurrent ?? 5;
588
+ return jsxs("div", { className: "rlr-modelRow", key, style: off ? { opacity: 0.55 } : undefined, children: [
589
+ jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 2, flex: 1, minWidth: 0 }, children: [
590
+ jsx("span", { className: "rlr-modelName", children: key }),
591
+ jsxs("span", { className: "rlr-modelMeta", children: [
592
+ jsx("span", { children: t("effective") + ": " + effRpm + " rpm / " + effConc + " " + t("concurrent") }),
593
+ ] }),
594
+ ] }),
595
+ jsxs("div", { style: { display: "flex", alignItems: "center", gap: 6 }, children: [
596
+ jsx("input", {
597
+ className: "rlr-input", type: "number", min: 1, style: { width: 64 }, title: t("maxRpm"),
598
+ disabled: !writable, value: o.maxRpm ?? "", placeholder: "rpm",
599
+ onChange: (e) => setModel(key, "maxRpm", e.target.value),
600
+ }),
601
+ jsx("input", {
602
+ className: "rlr-input", type: "number", min: 1, style: { width: 56 }, title: t("maxConcurrent"),
603
+ disabled: !writable, value: o.maxConcurrent ?? "", placeholder: t("concurrent"),
604
+ onChange: (e) => setModel(key, "maxConcurrent", e.target.value),
605
+ }),
606
+ bucket
607
+ ? jsx("input", {
608
+ className: "rlr-input", type: "number", min: 1, style: { width: 56 }, title: t("burstSize"),
609
+ disabled: !writable, value: o.burstSize ?? "", placeholder: t("burstSize"),
610
+ onChange: (e) => setModel(key, "burstSize", e.target.value),
611
+ })
612
+ : null,
613
+ jsx("button", { className: "rlr-btnSec", disabled: !writable, onClick: () => toggleModel(key), children: off ? t("enable") : t("disable") }),
614
+ confirmKey === key
615
+ ? jsxs("span", { style: { display: "flex", gap: 4, alignItems: "center" }, children: [
616
+ jsx("button", {
617
+ className: "rlr-danger",
618
+ disabled: !writable,
619
+ onClick: () => { removeModel(key); setConfirmKey(""); },
620
+ children: t("confirmDelete"),
621
+ }),
622
+ jsx("button", { className: "rlr-btnSec", onClick: () => setConfirmKey(""), children: t("cancel") }),
623
+ ] })
624
+ : jsx("button", {
625
+ className: "rlr-danger",
626
+ disabled: !writable,
627
+ onClick: () => setConfirmKey(key),
628
+ title: t("remove"),
629
+ children: "✕",
630
+ }),
631
+ ] }),
632
+ ] });
633
+ }),
634
+ jsxs("div", { className: "rlr-row", children: [
635
+ jsx("input", {
636
+ className: "rlr-input",
637
+ style: { width: 240 },
638
+ placeholder: t("addPlaceholder"),
639
+ value: draftKey,
640
+ disabled: !writable,
641
+ onChange: (e) => setDraftKey(e.target.value),
642
+ onKeyDown: (e) => { if (e.key === "Enter") addModel(); },
643
+ }),
644
+ jsx("button", { className: "rlr-btn", disabled: !writable, onClick: addModel, children: t("addModel") }),
645
+ ] }),
646
+ ] }),
647
+
648
+ /* ── provider 级配置 ── */
649
+ jsxs("div", { className: "rlr-section", children: [
650
+ jsx("div", { className: "rlr-sectHead", children: t("providers") }),
651
+ jsx("div", { className: "rlr-status", children: t("providersHint") }),
652
+ providerKeys.length === 0
653
+ ? jsx("p", { className: "rlr-status", children: t("noProviders") })
654
+ : providerKeys.map((key) => {
655
+ const o = cfg.providers?.[key] ?? {};
656
+ return jsxs("div", { className: "rlr-modelRow", key, children: [
657
+ jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 2, flex: 1, minWidth: 0 }, children: [
658
+ jsx("span", { className: "rlr-modelName", children: key + "/*" }),
659
+ jsx("span", { className: "rlr-modelMeta", children: t("providerScope") }),
660
+ ] }),
661
+ jsxs("div", { style: { display: "flex", alignItems: "center", gap: 6 }, children: [
662
+ jsx("input", {
663
+ className: "rlr-input", type: "number", min: 1, style: { width: 64 }, title: t("maxRpm"),
664
+ disabled: !writable, value: o.maxRpm ?? "", placeholder: "rpm",
665
+ onChange: (e) => setProvider(key, "maxRpm", e.target.value),
666
+ }),
667
+ jsx("input", {
668
+ className: "rlr-input", type: "number", min: 1, style: { width: 56 }, title: t("maxConcurrent"),
669
+ disabled: !writable, value: o.maxConcurrent ?? "", placeholder: t("concurrent"),
670
+ onChange: (e) => setProvider(key, "maxConcurrent", e.target.value),
671
+ }),
672
+ jsx("button", { className: "rlr-danger", disabled: !writable, onClick: () => removeProvider(key), title: t("remove"), children: "✕" }),
673
+ ] }),
674
+ ] });
675
+ }),
676
+ jsxs("div", { className: "rlr-row", children: [
677
+ jsx("input", {
678
+ className: "rlr-input",
679
+ style: { width: 200 },
680
+ placeholder: t("addProviderPlaceholder"),
681
+ value: draftProvider,
682
+ disabled: !writable,
683
+ onChange: (e) => setDraftProvider(e.target.value),
684
+ onKeyDown: (e) => { if (e.key === "Enter") addProvider(); },
685
+ }),
686
+ jsx("button", { className: "rlr-btn", disabled: !writable, onClick: addProvider, children: t("addProvider") }),
687
+ ] }),
688
+ ] }),
689
+
690
+ /* ── 429 自适应降速 ── */
691
+ jsxs("div", { className: "rlr-section", children: [
692
+ jsx("div", { className: "rlr-sectHead", children: t("cooldown") }),
693
+ jsx("div", { className: "rlr-status", children: t("cooldownHint") }),
694
+ jsxs("div", { className: "rlr-row", children: [
695
+ jsxs("label", { style: { display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }, children: [
696
+ jsx("input", {
697
+ type: "checkbox",
698
+ checked: cooldown.enabled ?? true,
699
+ disabled: !writable,
700
+ onChange: (e) => setCooldown("enabled", e.target.checked),
701
+ }),
702
+ t("cooldownEnabled"),
703
+ ] }),
704
+ ] }),
705
+ jsx(NumField, { label: t("cooldownMs"), value: cooldown.cooldownMs, placeholder: "30000", min: 1000, step: 1000, disabled: !writable, onChange: (v) => setCooldown("cooldownMs", Number(v) || 30000) }),
706
+ jsx(NumField, { label: t("cooldownFactor"), value: cooldown.factor, placeholder: "0.5", min: 0.05, step: 0.05, disabled: !writable, onChange: (v) => setCooldown("factor", Number(v) || 0.5) }),
707
+ jsx(NumField, { label: t("recoverAfter"), value: cooldown.recoverAfter, placeholder: "3", min: 1, step: 1, disabled: !writable, onChange: (v) => setCooldown("recoverAfter", Number(v) || 3) }),
708
+ ] }),
709
+
710
+ /* ── 配置导入 / 导出 ── */
711
+ jsxs("div", { className: "rlr-section", children: [
712
+ jsx("div", { className: "rlr-sectHead", children: t("preset") }),
713
+ jsx("div", { className: "rlr-status", children: t("presetHint") }),
714
+ jsxs("div", { className: "rlr-row", children: [
715
+ jsx("button", { className: "rlr-btn", onClick: exportConfig, children: t("exportConfig") }),
716
+ jsx("button", {
717
+ className: "rlr-btn",
718
+ disabled: !writable,
719
+ onClick: () => { if (fileInputRef.current) fileInputRef.current.click(); },
720
+ children: t("importConfig"),
721
+ }),
722
+ jsx("input", {
723
+ ref: fileInputRef,
724
+ type: "file",
725
+ accept: "application/json,.json",
726
+ style: { display: "none" },
727
+ onChange: onPickFile,
728
+ }),
729
+ ] }),
730
+ importError !== ""
731
+ ? jsx("div", { className: "rlr-status", style: { color: "var(--fg-danger, #d9534f)" }, children: importError })
732
+ : null,
733
+ ] }),
734
+
735
+ /* ── 被限速时的行为 ── */
736
+ jsxs("div", { className: "rlr-section", children: [
737
+ jsx("div", { className: "rlr-sectHead", children: t("behavior") }),
738
+ jsxs("div", { className: "rlr-row", children: [
739
+ jsxs("label", { style: { display: "flex", alignItems: "center", gap: 4, cursor: "pointer" }, children: [
740
+ jsx("input", {
741
+ type: "radio", name: "mlr-on-throttle", disabled: !writable,
742
+ checked: cfg.onThrottled !== "reject",
743
+ onChange: () => setTop("onThrottled", "queue"),
744
+ }),
745
+ t("queue"),
746
+ ] }),
747
+ jsxs("label", { style: { display: "flex", alignItems: "center", gap: 4, cursor: "pointer" }, children: [
748
+ jsx("input", {
749
+ type: "radio", name: "mlr-on-throttle", disabled: !writable,
750
+ checked: cfg.onThrottled === "reject",
751
+ onChange: () => setTop("onThrottled", "reject"),
752
+ }),
753
+ t("reject"),
754
+ ] }),
755
+ ] }),
756
+ cfg.onThrottled !== "reject"
757
+ ? jsx(NumField, { label: t("maxQueueWait"), value: cfg.maxQueueWaitMs, placeholder: "60000", min: 1000, step: 1000, disabled: !writable, onChange: (v) => setTop("maxQueueWaitMs", Number(v) || 60000) })
758
+ : null,
759
+ ] }),
760
+
761
+ /* ── 落盘说明 ── */
762
+ jsx("div", { className: "rlr-status", children: t("saveHint") }),
763
+ ] });
764
+ }
765
+
766
+ /* ── Sidebar icon ─────────────────────────────────────────────────── */
767
+ /**
768
+ * Icon for the root sidebar panel list: a gauge with a needle, which reads
769
+ * as "rate / throttle" at 16px and stays legible in the collapsed rail.
770
+ *
771
+ * @param props - slot props (`size` is supplied by the sidebar row).
772
+ */
773
+ function PanelIcon(props) {
774
+ const size = typeof props.size === "number" ? props.size : 16;
775
+ return jsx("svg", {
776
+ width: size,
777
+ height: size,
778
+ viewBox: "0 0 24 24",
779
+ fill: "none",
780
+ stroke: "currentColor",
781
+ strokeWidth: 1.7,
782
+ strokeLinecap: "round",
783
+ strokeLinejoin: "round",
784
+ "aria-hidden": "true",
785
+ children: [
786
+ jsx("path", { d: "M4.2 18a8.5 8.5 0 1 1 15.6 0" }),
787
+ jsx("path", { d: "M12 13.4 16.4 9" }),
788
+ jsx("circle", { cx: 12, cy: 14, r: 1.5 }),
789
+ jsx("path", { d: "M3 21h18" }),
790
+ ],
791
+ });
792
+ }
793
+
794
+ /* ── Plugin registration ──────────────────────────────────────────── */
795
+ /**
796
+ * Register the Settings section, the dictionaries it reads, and the form
797
+ * it edits. The section lives in the Settings navigation, so it does not
798
+ * depend on any other page owning a slot this plugin could not reach.
799
+ *
800
+ * @param ctx - the browser plugin context.
801
+ */
802
+ function apply(ctx) {
803
+ const t = ctx.locale.bind(NS);
804
+ ctx.effect(() => ctx.locale.register(NS, {
805
+ zh: Object.assign({}, zh, {
806
+ nav: "多模型限速",
807
+ enabled: "启用限速",
808
+ defaults: "默认配置",
809
+ strategy: "策略",
810
+ tokenBucket: "令牌桶 (Token Bucket)",
811
+ slidingWindow: "滑动窗口 (Sliding Window)",
812
+ maxConcurrent: "最大并发数",
813
+ maxRpm: "每分钟最大请求数",
814
+ burstSize: "突发容量",
815
+ refillRate: "补充速率 (个/秒)",
816
+ models: "模型专属配置",
817
+ modelsHint: "格式 provider/model;留空即继承默认值。",
818
+ noModels: "未配置任何模型专属限速,所有模型使用默认值。",
819
+ addModel: "+ 添加模型",
820
+ addPlaceholder: "例如 openai/gpt-5.1",
821
+ effective: "实际生效",
822
+ concurrent: "并发",
823
+ enable: "启用",
824
+ disable: "禁用",
825
+ remove: "删除",
826
+ behavior: "被限速时的行为",
827
+ queue: "排队等待",
828
+ reject: "拒绝请求",
829
+ maxQueueWait: "最大排队等待 (ms)",
830
+ saveHint: "改动即写入 profile 的 cordis.patch.yml,无需重启。",
831
+ searchPlaceholder: "搜索模型…",
832
+ noMatches: "没有匹配的模型。",
833
+ confirmDelete: "确认删除",
834
+ cancel: "取消",
835
+ providers: "Provider 级配置",
836
+ providersHint: "作用于该 provider 下所有未单独配置的模型;优先级低于模型专属配置。",
837
+ noProviders: "未配置 provider 级限速。",
838
+ addProvider: "+ 添加 provider",
839
+ addProviderPlaceholder: "例如 openai",
840
+ providerScope: "作用于该 provider 的全部模型",
841
+ cooldown: "429 自适应降速",
842
+ cooldownHint: "provider 返回 429 时临时调低该模型的速率,连续成功后再恢复。",
843
+ cooldownEnabled: "遇到 429 时自动降速",
844
+ cooldownMs: "降速持续时间 (ms)",
845
+ cooldownFactor: "降速系数 (0.05–0.9)",
846
+ recoverAfter: "恢复所需的连续成功次数",
847
+ preset: "配置导入 / 导出",
848
+ presetHint: "导出的 JSON 可在另一台机器导入,覆盖当前全部设置。",
849
+ exportConfig: "导出 JSON",
850
+ importConfig: "导入 JSON",
851
+ importInvalidJson: "导入失败:不是合法 JSON。",
852
+ importInvalidShape: "导入失败:需要限速配置对象。",
853
+ importReadOnly: "导入失败:当前配置只读。",
854
+ importEmpty: "导入失败:文件里没有可识别的限速配置字段。",
855
+ importUnreadable: "导入失败:无法读取该文件。",
856
+ }),
857
+ en: Object.assign({}, en, {
858
+ nav: "Multi-model limits",
859
+ enabled: "Rate limiting enabled",
860
+ defaults: "Defaults",
861
+ strategy: "Strategy",
862
+ tokenBucket: "Token bucket",
863
+ slidingWindow: "Sliding window",
864
+ maxConcurrent: "Max concurrent",
865
+ maxRpm: "Max requests / minute",
866
+ burstSize: "Burst size",
867
+ refillRate: "Refill rate (per second)",
868
+ models: "Per-model limits",
869
+ modelsHint: "Format provider/model; leave a field empty to inherit the default.",
870
+ noModels: "No per-model limit yet — every model uses the defaults.",
871
+ addModel: "+ Add model",
872
+ addPlaceholder: "e.g. openai/gpt-5.1",
873
+ effective: "Effective",
874
+ concurrent: "concurrent",
875
+ enable: "Enable",
876
+ disable: "Disable",
877
+ remove: "Remove",
878
+ behavior: "When throttled",
879
+ queue: "Wait in queue",
880
+ reject: "Reject the request",
881
+ maxQueueWait: "Max queue wait (ms)",
882
+ saveHint: "Edits are written to the profile's cordis.patch.yml immediately.",
883
+ searchPlaceholder: "Search models…",
884
+ noMatches: "No model matches this filter.",
885
+ confirmDelete: "Delete",
886
+ cancel: "Cancel",
887
+ providers: "Per-provider limits",
888
+ providersHint: "Applies to every model of that provider that has no per-model limit.",
889
+ noProviders: "No per-provider limit yet.",
890
+ addProvider: "+ Add provider",
891
+ addProviderPlaceholder: "e.g. openai",
892
+ providerScope: "covers every model of this provider",
893
+ cooldown: "Adaptive 429 backoff",
894
+ cooldownHint: "After the provider answers 429, this model runs slower until calls succeed again.",
895
+ cooldownEnabled: "Slow down automatically after a 429",
896
+ cooldownMs: "Cooldown duration (ms)",
897
+ cooldownFactor: "Speed factor (0.05–0.9)",
898
+ recoverAfter: "Consecutive successes to recover",
899
+ preset: "Import / export",
900
+ presetHint: "Exported JSON can be imported on another machine; it replaces every setting.",
901
+ exportConfig: "Export JSON",
902
+ importConfig: "Import JSON",
903
+ importInvalidJson: "Import failed: not valid JSON.",
904
+ importInvalidShape: "Import failed: expected a limiter configuration object.",
905
+ importReadOnly: "Import failed: the configuration is read-only.",
906
+ importEmpty: "Import failed: the file has no recognisable limiter settings.",
907
+ importUnreadable: "Import failed: the file could not be read.",
908
+ }),
909
+ }), "llm-multi-rate-limiter: dictionaries");
910
+
911
+ // The page edits this plugin's own namespace: `get` creates the one
912
+ // shared form for it and disposes with this fiber, so the page needs no
913
+ // `whileServed` gate (it owns the entry).
914
+ const form = ctx.configForms.get(name);
915
+ const inject = () => ({
916
+ form,
917
+ t,
918
+ hooks: { snapshot: form.store },
919
+ statusCall: () => rpcCallOf(ctx),
920
+ });
921
+
922
+ // Two surfaces, one page: the Settings section (native settings nav) and
923
+ // a root sidebar panel (icon + label list, `main` keyed by the same id)
924
+ // so the limiter is reachable without opening Settings at all.
925
+ ctx.effect(() => ctx.slots.inject("settings.section", () => ctx.slots.register({
926
+ name: "settings.section",
927
+ id: name,
928
+ order: 15,
929
+ label: () => t("nav"),
930
+ locale: NS,
931
+ inject,
932
+ }, SectionPage)), "llm-multi-rate-limiter: settings section");
933
+
934
+ ctx.effect(() => ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
935
+ name: "sidebar.panellist",
936
+ id: name,
937
+ order: 40,
938
+ label: () => t("nav"),
939
+ locale: NS,
940
+ }, PanelIcon)), "llm-multi-rate-limiter: sidebar entry");
941
+
942
+ ctx.effect(() => ctx.slots.inject("main", () => ctx.slots.register({
943
+ name: "main",
944
+ key: name,
945
+ locale: NS,
946
+ inject,
947
+ }, SectionPage)), "llm-multi-rate-limiter: sidebar panel");
948
+ }
949
+
950
+ exports.apply = apply;
951
+ exports.inject = inject;
952
+ exports.name = name;
953
+ exports.NS = NS;
954
+ return module.exports;
955
+ },
956
+ });