@leaf233/dsh-llm-rate-limiter 0.2.1 → 0.3.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 CHANGED
@@ -2,8 +2,17 @@
2
2
  /**
3
3
  * dsh-llm-rate-limiter — Client (browser) entry point.
4
4
  *
5
- * Registers a "Rate Limiter" card in the Plugins → Configurable settings page.
6
- * The card uses a collapsible header (PluginCard pattern) with a chevron toggle.
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.
7
16
  *
8
17
  * Browser format: CJS-style factory registered via window.__ModuleLoader__.load
9
18
  * (loaded as a plain <script>, so NO top-level ESM `export` allowed).
@@ -23,23 +32,35 @@ window.__ModuleLoader__.load({
23
32
  ({ jsx, jsxs } = require("react/jsx-runtime"));
24
33
 
25
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
+ */
26
40
  const name = "llm-rate-limiter";
27
- const inject = ["slots", "settingsScope"];
41
+ /** Locale dictionary namespace owned by this plugin. */
42
+ const NS = "settings.llmRateLimiter";
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
+ };
28
59
 
29
60
  /* ── CSS-in-JS (design tokens, injected once) ────────────────────── */
30
61
  (() => {
31
62
  const styles = [
32
- ".rlr-card{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-3);border-radius:16px;list-style:none;transition:border-color .16s,background .16s;margin:0}",
33
- ".rlr-card:hover{border-color:var(--dsw-alias-label-dimmed)}",
34
- ".rlr-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}",
35
- ".rlr-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}",
36
- ".rlr-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}",
37
- ".rlr-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}",
38
- ".rlr-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}",
39
- ".rlr-description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}",
40
- ".rlr-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s;font-size:12px}",
41
- ".rlr-chevronOpen{transform:rotate(180deg)}",
42
- ".rlr-body{border-top:.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding:8px 0 8px}",
63
+ ".rlr-page{display:flex;flex-direction:column;gap:4px;list-style:none;margin:0}",
43
64
  ".rlr-status{color:var(--dsw-alias-label-tertiary);margin:8px 0 4px;font-size:12px;line-height:1.5}",
44
65
  ".rlr-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:6px 0}",
45
66
  ".rlr-label{min-width:140px;font-size:13px;color:var(--dsw-alias-label-secondary)}",
@@ -96,12 +117,6 @@ window.__ModuleLoader__.load({
96
117
  function readNum(cfg, key, field) {
97
118
  return cfg.models?.[key]?.[field] ?? cfg.defaults?.[field] ?? 0;
98
119
  }
99
- function setNested(scope, path, value) {
100
- scope.mutate([{ op: "set", path, value }]);
101
- }
102
- function unsetNested(scope, path) {
103
- scope.mutate([{ op: "unset", path }]);
104
- }
105
120
 
106
121
  /* ── status channel access (dsh-context defensive idiom) ─────────── */
107
122
  const STATUS_CHANNEL = "/llm-rate-limiter";
@@ -109,7 +124,7 @@ window.__ModuleLoader__.load({
109
124
  /**
110
125
  * The connection's bound generic-RPC caller, or undefined when the
111
126
  * service is absent or hostile. Every read is guarded, so this can
112
- * never throw — a missing channel degrades the panel, not the card.
127
+ * never throw — a missing channel degrades the panel, not the page.
113
128
  */
114
129
  function rpcCallOf(ctx) {
115
130
  try {
@@ -304,164 +319,191 @@ window.__ModuleLoader__.load({
304
319
  ] });
305
320
  }
306
321
 
307
- /* ── React component: collapsible card ───────────────────────────── */
308
- function RateLimiterCard({ settingsScope, statusCall }) {
309
- const [cfg, setCfg] = react.useState(() => {
310
- const snap = settingsScope.getSnapshot?.();
311
- return snap?.status === "ready" && snap.value ? { ...snap.value } : {};
312
- });
313
-
314
- react.useEffect(() => {
315
- const unsub = settingsScope.subscribe?.(() => {
316
- const snap = settingsScope.getSnapshot();
317
- if (snap?.status === "ready" && snap.value) setCfg({ ...snap.value });
318
- });
319
- return () => (typeof unsub === "function" ? unsub() : undefined);
320
- }, [settingsScope]);
322
+ /* ── React component: the settings page (summary + page views) ───── */
323
+ /**
324
+ * Render the one-liner or the configuration body, as the Plugins page
325
+ * asks. Configuration values arrive in `props.form.state` (a
326
+ * `ConfigFormSnapshot`: `{status, value, base, user, revision,
327
+ * writable, mode}`) and writes go through `props.form.mutate(ops)` with
328
+ * the same `{op:'set'|'unset', path, value}` operations the Host
329
+ * validated before. The page owns the form, so it re-renders this entry
330
+ * whenever the settings mirror changes — the component keeps no
331
+ * subscription of its own.
332
+ *
333
+ * @param props - `view`, the page-owned `form`, and the injected
334
+ * `statusCall` (plus the framework's `t` locale seat).
335
+ */
336
+ function RateLimiterCard(props) {
337
+ const t = typeof props.t === "function" ? props.t : (key) => key;
338
+
339
+ if (props.view === "summary") return jsx("span", { children: t("description") });
340
+
341
+ const form = props.form;
342
+ const snap = form?.state;
343
+ const cfg = snap?.status === "ready" && snap.value ? snap.value : {};
344
+ const writable = snap === undefined ? true : snap.writable !== false;
345
+ const statusCall = props.statusCall;
346
+
347
+ /** Queue one atomic namespace mutation; failures leave the form as-is. */
348
+ function push(ops) {
349
+ if (!form || typeof form.mutate !== "function") return;
350
+ try {
351
+ Promise.resolve(form.mutate(ops)).catch(() => undefined);
352
+ } catch {}
353
+ }
354
+ const setPath = (path, value) => push([{ op: "set", path, value }]);
355
+ const unsetPath = (path) => push([{ op: "unset", path }]);
321
356
 
322
357
  const modelKeys = Object.keys(cfg.models ?? {});
323
358
 
324
359
  function addModel() {
325
- const key = prompt('添加模型限速 (格式: provider/model)\n例如: deepseek/deepseek-chat');
360
+ const key = prompt("添加模型限速 (格式: provider/model)\n例如: deepseek/deepseek-chat");
326
361
  if (!key) return;
327
- setNested(settingsScope, ["models", key], {});
362
+ setPath(["models", key], {});
328
363
  }
329
364
  function removeModel(key) {
330
- unsetNested(settingsScope, ["models", key]);
365
+ unsetPath(["models", key]);
331
366
  }
332
367
  function setModelField(key, field, value) {
333
368
  const path = ["models", key, field];
334
- if (value === undefined || value === "") unsetNested(settingsScope, path);
335
- else setNested(settingsScope, path, value);
369
+ if (value === undefined || value === "") unsetPath(path);
370
+ else setPath(path, value);
336
371
  }
337
372
  function setDefault(field, value) {
338
- setNested(settingsScope, ["defaults", field], value);
373
+ setPath(["defaults", field], value);
339
374
  }
340
375
  function setTop(field, value) {
341
- settingsScope.set(field, value);
376
+ setPath([field], value);
342
377
  }
343
378
 
344
- const [open, setOpen] = react.useState(false);
345
-
346
- return jsxs("li", { className: "rlr-card" + (open ? " rlr-cardOpen" : ""), children: [
347
- jsxs("button", {
348
- type: "button",
349
- className: "rlr-header",
350
- "aria-expanded": open,
351
- onClick: () => setOpen(!open),
352
- children: [
353
- jsxs("span", { className: "rlr-headText", children: [
354
- jsx("span", { className: "rlr-name", children: "⚙ LLM 调用限速" }),
355
- jsx("span", { className: "rlr-description", children: "按模型限制 LLM 请求频率,超出限制的请求排队等待" }),
356
- ] }),
357
- jsx("span", { className: "rlr-chevron" + (open ? " rlr-chevronOpen" : ""), children: "▾" }),
358
- ]
359
- }),
360
- open && jsxs("div", { className: "rlr-body", children: [
361
- /* ── 实时状态面板(折叠即卸载 → 停止轮询) ── */
362
- jsx(RateLimiterStatus, { statusCall, cfg }),
363
- /* ── 启用开关 ── */
379
+ return jsxs("div", { className: "rlr-page", children: [
380
+ /* ── unavailable / read-only notices ── */
381
+ snap?.status === "unavailable"
382
+ ? jsx("div", { className: "rlr-status", children: t("unavailable") })
383
+ : null,
384
+ snap !== undefined && !writable
385
+ ? jsx("div", { className: "rlr-status", children: t("readOnly") })
386
+ : null,
387
+
388
+ /* ── 实时状态面板 ── */
389
+ jsx(RateLimiterStatus, { statusCall, cfg }),
390
+
391
+ /* ── 启用开关 ── */
392
+ jsxs("div", { className: "rlr-row", children: [
393
+ jsx("label", { children: [
394
+ jsx("input", { type: "checkbox", checked: cfg.enabled ?? true, disabled: !writable, onChange: (e) => setTop("enabled", e.target.checked) }),
395
+ " 启用限速",
396
+ ] }),
397
+ ] }),
398
+ /* ── 默认配置 ── */
399
+ jsxs("div", { className: "rlr-section", children: [
400
+ jsx("div", { className: "rlr-sectHead", children: "默认配置" }),
364
401
  jsxs("div", { className: "rlr-row", children: [
365
- jsx("label", { children: [
366
- jsx("input", { type: "checkbox", checked: cfg.enabled ?? true, onChange: (e) => setTop("enabled", e.target.checked) }),
367
- " 启用限速",
402
+ jsx("span", { className: "rlr-label", children: "策略" }),
403
+ jsxs("select", { className: "rlr-select", value: cfg.strategy ?? "token-bucket", disabled: !writable, onChange: (e) => setTop("strategy", e.target.value), children: [
404
+ jsx("option", { value: "token-bucket", children: "令牌桶 (Token Bucket)" }),
405
+ jsx("option", { value: "sliding-window", children: "滑动窗口 (Sliding Window)" }),
368
406
  ] }),
369
407
  ] }),
370
- /* ── 默认配置 ── */
371
- jsxs("div", { className: "rlr-section", children: [
372
- jsx("div", { className: "rlr-sectHead", children: "默认配置" }),
373
- jsxs("div", { className: "rlr-row", children: [
374
- jsx("span", { className: "rlr-label", children: "策略" }),
375
- jsxs("select", { className: "rlr-select", value: cfg.strategy ?? "token-bucket", onChange: (e) => setTop("strategy", e.target.value), children: [
376
- jsx("option", { value: "token-bucket", children: "令牌桶 (Token Bucket)" }),
377
- jsx("option", { value: "sliding-window", children: "滑动窗口 (Sliding Window)" }),
378
- ] }),
379
- ] }),
408
+ jsxs("div", { className: "rlr-row", children: [
409
+ jsx("span", { className: "rlr-label", children: "最大并发数" }),
410
+ jsx("input", { type: "number", min: 1, className: "rlr-input", disabled: !writable, value: cfg.defaults?.maxConcurrent ?? 5, onChange: (e) => setDefault("maxConcurrent", Number(e.target.value) || 1) }),
411
+ ] }),
412
+ jsxs("div", { className: "rlr-row", children: [
413
+ jsx("span", { className: "rlr-label", children: "每分钟最大请求数" }),
414
+ jsx("input", { type: "number", min: 1, className: "rlr-input", disabled: !writable, value: cfg.defaults?.maxRpm ?? 60, onChange: (e) => setDefault("maxRpm", Number(e.target.value) || 1) }),
415
+ ] }),
416
+ cfg.strategy !== "sliding-window" && jsxs(react.Fragment, { children: [
380
417
  jsxs("div", { className: "rlr-row", children: [
381
- jsx("span", { className: "rlr-label", children: "最大并发数" }),
382
- jsx("input", { type: "number", min: 1, className: "rlr-input", value: cfg.defaults?.maxConcurrent ?? 5, onChange: (e) => setDefault("maxConcurrent", Number(e.target.value) || 1) }),
418
+ jsx("span", { className: "rlr-label", children: "突发容量" }),
419
+ jsx("input", { type: "number", min: 1, className: "rlr-input", disabled: !writable, value: cfg.defaults?.burstSize ?? 10, onChange: (e) => setDefault("burstSize", Number(e.target.value) || 1) }),
383
420
  ] }),
384
421
  jsxs("div", { className: "rlr-row", children: [
385
- jsx("span", { className: "rlr-label", children: "每分钟最大请求数" }),
386
- jsx("input", { type: "number", min: 1, className: "rlr-input", value: cfg.defaults?.maxRpm ?? 60, onChange: (e) => setDefault("maxRpm", Number(e.target.value) || 1) }),
387
- ] }),
388
- cfg.strategy !== "sliding-window" && jsxs(react.Fragment, { children: [
389
- jsxs("div", { className: "rlr-row", children: [
390
- jsx("span", { className: "rlr-label", children: "突发容量" }),
391
- jsx("input", { type: "number", min: 1, className: "rlr-input", value: cfg.defaults?.burstSize ?? 10, onChange: (e) => setDefault("burstSize", Number(e.target.value) || 1) }),
392
- ] }),
393
- jsxs("div", { className: "rlr-row", children: [
394
- jsx("span", { className: "rlr-label", children: "补充速率 (个/秒)" }),
395
- jsx("input", { type: "number", min: 0.1, step: 0.1, className: "rlr-input", value: cfg.defaults?.refillRate ?? 1, onChange: (e) => setDefault("refillRate", Number(e.target.value) || 0.1) }),
396
- ] }),
422
+ jsx("span", { className: "rlr-label", children: "补充速率 (个/秒)" }),
423
+ jsx("input", { type: "number", min: 0.1, step: 0.1, className: "rlr-input", disabled: !writable, value: cfg.defaults?.refillRate ?? 1, onChange: (e) => setDefault("refillRate", Number(e.target.value) || 0.1) }),
397
424
  ] }),
398
425
  ] }),
399
- /* ── 模型专属配置 ── */
400
- jsxs("div", { className: "rlr-section", children: [
401
- jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center" }, children: [
402
- jsx("div", { className: "rlr-sectHead", children: "模型专属配置" }),
403
- jsx("button", { className: "rlr-btn", onClick: addModel, children: "+ 添加模型" }),
404
- ] }),
405
- modelKeys.length === 0 && jsx("p", { className: "rlr-status", children: "未配置模型专属限速,所有模型使用默认值。" }),
406
- modelKeys.map((key) => jsxs("div", { className: "rlr-modelRow", key, children: [
407
- jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4, flex: 1 }, children: [
408
- jsx("span", { className: "rlr-modelName", children: key }),
409
- jsxs("div", { className: "rlr-modelMeta", children: [
410
- jsx("span", { children: `并发: ${readNum(cfg, key, "maxConcurrent")}` }),
411
- jsx("span", { children: `RPM: ${readNum(cfg, key, "maxRpm")}` }),
412
- cfg.strategy !== "sliding-window" && jsx("span", { children: `突发: ${readNum(cfg, key, "burstSize")}` }),
413
- ] }),
414
- jsxs("div", { style: { display: "flex", alignItems: "center", gap: 6, marginTop: 4 }, children: [
415
- jsx("input", { type: "number", min: 1, className: "rlr-input", style: { width: 60 }, title: "并发", placeholder: "并发", value: cfg.models?.[key]?.maxConcurrent ?? "", onChange: (e) => setModelField(key, "maxConcurrent", e.target.value === "" ? undefined : Number(e.target.value) || 1) }),
416
- jsx("input", { type: "number", min: 1, className: "rlr-input", style: { width: 60 }, title: "RPM", placeholder: "RPM", value: cfg.models?.[key]?.maxRpm ?? "", onChange: (e) => setModelField(key, "maxRpm", e.target.value === "" ? undefined : Number(e.target.value) || 1) }),
417
- cfg.strategy !== "sliding-window" && jsx("input", { type: "number", min: 1, className: "rlr-input", style: { width: 60 }, title: "突发", placeholder: "突发", value: cfg.models?.[key]?.burstSize ?? "", onChange: (e) => setModelField(key, "burstSize", e.target.value === "" ? undefined : Number(e.target.value) || 1) }),
418
- jsx("button", { className: "rlr-btnSec", onClick: () => { const v = cfg.models?.[key]?.enabled; setModelField(key, "enabled", v === false ? undefined : false); }, children: cfg.models?.[key]?.enabled === false ? "已禁用" : "禁用" }),
419
- ] }),
420
- ] }),
421
- jsx("button", { className: "rlr-danger", onClick: () => removeModel(key), title: "删除", children: "✕" }),
422
- ] })),
426
+ ] }),
427
+ /* ── 模型专属配置 ── */
428
+ jsxs("div", { className: "rlr-section", children: [
429
+ jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center" }, children: [
430
+ jsx("div", { className: "rlr-sectHead", children: "模型专属配置" }),
431
+ jsx("button", { className: "rlr-btn", disabled: !writable, onClick: addModel, children: "+ 添加模型" }),
423
432
  ] }),
424
- /* ── 被限速时的行为 ── */
425
- jsxs("div", { className: "rlr-section", children: [
426
- jsx("div", { className: "rlr-sectHead", children: "被限速时的行为" }),
427
- jsxs("div", { className: "rlr-row", children: [
428
- jsx("label", { style: { display: "flex", alignItems: "center", gap: 4, cursor: "pointer" }, children: [
429
- jsx("input", { type: "radio", name: "rl-on-throttle", checked: cfg.onThrottled !== "reject", onChange: () => setTop("onThrottled", "queue") }),
430
- "排队等待",
433
+ modelKeys.length === 0 && jsx("p", { className: "rlr-status", children: "未配置模型专属限速,所有模型使用默认值。" }),
434
+ modelKeys.map((key) => jsxs("div", { className: "rlr-modelRow", key, children: [
435
+ jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4, flex: 1 }, children: [
436
+ jsx("span", { className: "rlr-modelName", children: key }),
437
+ jsxs("div", { className: "rlr-modelMeta", children: [
438
+ jsx("span", { children: "并发: " + readNum(cfg, key, "maxConcurrent") }),
439
+ jsx("span", { children: "RPM: " + readNum(cfg, key, "maxRpm") }),
440
+ cfg.strategy !== "sliding-window" && jsx("span", { children: "突发: " + readNum(cfg, key, "burstSize") }),
431
441
  ] }),
432
- jsx("label", { style: { display: "flex", alignItems: "center", gap: 4, cursor: "pointer" }, children: [
433
- jsx("input", { type: "radio", name: "rl-on-throttle", checked: cfg.onThrottled === "reject", onChange: () => setTop("onThrottled", "reject") }),
434
- "拒绝请求",
442
+ jsxs("div", { style: { display: "flex", alignItems: "center", gap: 6, marginTop: 4 }, children: [
443
+ jsx("input", { type: "number", min: 1, className: "rlr-input", disabled: !writable, style: { width: 60 }, title: "并发", placeholder: "并发", value: cfg.models?.[key]?.maxConcurrent ?? "", onChange: (e) => setModelField(key, "maxConcurrent", e.target.value === "" ? undefined : Number(e.target.value) || 1) }),
444
+ jsx("input", { type: "number", min: 1, className: "rlr-input", disabled: !writable, style: { width: 60 }, title: "RPM", placeholder: "RPM", value: cfg.models?.[key]?.maxRpm ?? "", onChange: (e) => setModelField(key, "maxRpm", e.target.value === "" ? undefined : Number(e.target.value) || 1) }),
445
+ cfg.strategy !== "sliding-window" && jsx("input", { type: "number", min: 1, className: "rlr-input", disabled: !writable, style: { width: 60 }, title: "突发", placeholder: "突发", value: cfg.models?.[key]?.burstSize ?? "", onChange: (e) => setModelField(key, "burstSize", e.target.value === "" ? undefined : Number(e.target.value) || 1) }),
446
+ jsx("button", { className: "rlr-btnSec", disabled: !writable, onClick: () => { const v = cfg.models?.[key]?.enabled; setModelField(key, "enabled", v === false ? undefined : false); }, children: cfg.models?.[key]?.enabled === false ? "已禁用" : "禁用" }),
435
447
  ] }),
436
448
  ] }),
437
- cfg.onThrottled !== "reject" && jsxs("div", { className: "rlr-row", children: [
438
- jsx("span", { className: "rlr-label", children: "最大排队等待 (ms)" }),
439
- jsx("input", { type: "number", min: 1000, step: 1000, className: "rlr-input", value: cfg.maxQueueWaitMs ?? 60000, onChange: (e) => setTop("maxQueueWaitMs", Number(e.target.value) || 60000) }),
449
+ jsx("button", { className: "rlr-danger", disabled: !writable, onClick: () => removeModel(key), title: "删除", children: "✕" }),
450
+ ] })),
451
+ ] }),
452
+ /* ── 被限速时的行为 ── */
453
+ jsxs("div", { className: "rlr-section", children: [
454
+ jsx("div", { className: "rlr-sectHead", children: "被限速时的行为" }),
455
+ jsxs("div", { className: "rlr-row", children: [
456
+ jsx("label", { style: { display: "flex", alignItems: "center", gap: 4, cursor: "pointer" }, children: [
457
+ jsx("input", { type: "radio", name: "rl-on-throttle", disabled: !writable, checked: cfg.onThrottled !== "reject", onChange: () => setTop("onThrottled", "queue") }),
458
+ "排队等待",
440
459
  ] }),
460
+ jsx("label", { style: { display: "flex", alignItems: "center", gap: 4, cursor: "pointer" }, children: [
461
+ jsx("input", { type: "radio", name: "rl-on-throttle", disabled: !writable, checked: cfg.onThrottled === "reject", onChange: () => setTop("onThrottled", "reject") }),
462
+ "拒绝请求",
463
+ ] }),
464
+ ] }),
465
+ cfg.onThrottled !== "reject" && jsxs("div", { className: "rlr-row", children: [
466
+ jsx("span", { className: "rlr-label", children: "最大排队等待 (ms)" }),
467
+ jsx("input", { type: "number", min: 1000, step: 1000, className: "rlr-input", disabled: !writable, value: cfg.maxQueueWaitMs ?? 60000, onChange: (e) => setTop("maxQueueWaitMs", Number(e.target.value) || 60000) }),
441
468
  ] }),
442
469
  ] }),
443
470
  ] });
444
471
  }
445
472
 
446
473
  /* ── Plugin registration ── */
474
+ /**
475
+ * Mount the configuration page while the Host serves this entry's
476
+ * namespace, and register the plugin's own dictionaries.
477
+ *
478
+ * @param ctx - the browser plugin context.
479
+ */
447
480
  function apply(ctx) {
448
- const scope = ctx.settingsScope.bind({ namespace: name });
449
- ctx.slots.inject("settings.plugin.item", function* () {
450
- yield ctx.slots.register({
451
- name: "settings.plugin.item",
452
- key: name,
453
- locale: name,
454
- // `statusCall` is resolved per render (never cached) so a
455
- // late-arriving or hot-reloaded connection service is picked
456
- // up without re-registering the card.
457
- inject: () => ({ settingsScope: scope, statusCall: () => rpcCallOf(ctx) }),
458
- }, RateLimiterCard);
459
- });
481
+ const t = ctx.locale.bind(NS);
482
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "llm-rate-limiter: dictionaries");
483
+
484
+ // `whileServed` keeps the card off the page entirely unless the Host
485
+ // exposes this namespace: `dsh-settings` drops any entry whose Config
486
+ // has no volatile field (`lib/index.js:419`), so an entry that never
487
+ // appears there has nothing to configure.
488
+ ctx.effect(() => ctx.configForms.whileServed([name], () => ctx.slots.inject("plugins.item", () => ctx.slots.register({
489
+ name: "plugins.item",
490
+ id: name,
491
+ // Official `plugins.item` occupants take 10/20/30/40; 900 parks
492
+ // this third-party card after all of them.
493
+ order: 900,
494
+ label: () => t("title"),
495
+ locale: NS,
496
+ // `statusCall` is resolved per render (never cached) so a
497
+ // late-arriving or hot-reloaded connection service is picked up
498
+ // without re-registering the page.
499
+ inject: () => ({ statusCall: () => rpcCallOf(ctx) }),
500
+ }, RateLimiterCard))), "llm-rate-limiter: plugins page");
460
501
  }
461
502
 
462
503
  exports.apply = apply;
463
504
  exports.inject = inject;
464
505
  exports.name = name;
506
+ exports.NS = NS;
465
507
  return module.exports;
466
- }
467
- });
508
+ },
509
+ });
package/lib/index.js CHANGED
@@ -2,28 +2,36 @@
2
2
  * dsh-llm-rate-limiter — Host (server-side) entry point.
3
3
  *
4
4
  * Intercepts the `llm/stream` waterfall and enforces per-model rate limits.
5
- * Configuration lives in the "llm-rate-limiter" settings namespace, editable
6
- * from the browser GUI.
5
+ *
6
+ * Configuration is the profile ENTRY's own `config` (this module's exported
7
+ * {@link Config} schema), read through the volatile references Cordis builds
8
+ * for it. `dsh-settings` projects the volatile fields of every active entry
9
+ * into a form the browser can edit, so the plugin ships no settings service
10
+ * of its own. (DSH 0.1.5 had a `ctx.settings.register()` namespace API; 0.1.7
11
+ * removed it, and its browser half dropped `settingsScope` for `configForms`.
12
+ * The migration record lives in CHANGELOG.md under `[0.3.0]`.)
7
13
  *
8
14
  * The `llm/stream` waterfall signature is:
9
15
  * (options: GenerateOptions, next: () => AsyncIterable<StreamChunk>) => AsyncIterable<StreamChunk>
10
16
  *
11
17
  * where `options` always has `.provider` (string) and `.model` (string).
12
18
  *
13
- * The settings service is injected dynamically (like dshmarket's
14
- * installMarketSettings): `ctx.inject(['settings'], (scopedCtx) => ...)`, so a
15
- * host without a settings provider simply runs without the GUI wiring — the
16
- * composed entry stays as configured. Inherited from the DSH house pattern.
17
- *
18
19
  * @module dsh-llm-rate-limiter
19
20
  */
20
21
 
21
22
  import { TokenBucketStrategy } from "./strategies/token-bucket.js";
22
23
  import { SlidingWindowStrategy } from "./strategies/sliding-window.js";
23
- import { RateLimiterConfig } from "./types/config.js";
24
+ import { Config } from "./types/config.js";
24
25
  import { CHANNEL, createChannelRoute, createStatusChannel } from "./status-rpc.js";
25
26
 
26
27
  const name = "llm-rate-limiter";
28
+ /**
29
+ * Settings namespace / profile entry id.
30
+ *
31
+ * `dsh-settings` keys every form by `entry.options.id` (`lib/index.js:432`),
32
+ * which is the `id` this plugin's `cordis.patch.yml` row declares. Renaming
33
+ * that row id therefore renames this namespace too — keep the two in step.
34
+ */
27
35
  const SETTINGS_NS = "llm-rate-limiter";
28
36
 
29
37
  /** Maximum retained events in the status ring (bounded memory). */
@@ -33,9 +41,45 @@ const SNAPSHOT_EVENT_COUNT = 8;
33
41
 
34
42
  /* ── Helpers ─────────────────────────────────────────────────────────── */
35
43
 
44
+ /** Built-in defaults, used when a reference yields nothing (or no config at all). */
45
+ const FALLBACK = {
46
+ enabled: true,
47
+ strategy: "token-bucket",
48
+ defaults: { maxConcurrent: 5, maxRpm: 60, burstSize: 10, refillRate: 1 },
49
+ models: {},
50
+ onThrottled: "queue",
51
+ maxQueueWaitMs: 60_000,
52
+ };
53
+
54
+ /**
55
+ * Read one volatile reference from the parsed Config.
56
+ *
57
+ * The loader mutates volatile references IN PLACE when only raw config values
58
+ * changed (`cordis-plugin-loader/lib/index.js:393` `_commitVolatile()`), so
59
+ * the reference object stays valid for the plugin's whole lifetime and its
60
+ * `get()` always yields the current value. Call this at each use rather than
61
+ * caching the unwrapped value.
62
+ *
63
+ * @param ref - a `.volatile()` field of the parsed Config, or undefined when
64
+ * the entry was mounted with no config at all.
65
+ * @param fallback - value used when the reference is absent.
66
+ * @returns the current plain value.
67
+ */
68
+ function read(ref, fallback) {
69
+ if (ref === undefined || ref === null) return fallback;
70
+ if (typeof ref.get === "function") {
71
+ const value = ref.get();
72
+ return value === undefined || value === null ? fallback : value;
73
+ }
74
+ return ref;
75
+ }
76
+
36
77
  /**
37
78
  * Merge defaults + per-model overrides into a flat config object that both
38
79
  * strategy constructors understand.
80
+ *
81
+ * @param cfg - plain config object (`{ enabled, strategy, defaults, models, ... }`).
82
+ * @param key - `"provider/model"` of the call being limited.
39
83
  */
40
84
  function resolveModelConfig(cfg, key) {
41
85
  const o = cfg.models?.[key] ?? {};
@@ -92,22 +136,26 @@ function terminalChunk(modelKey, signal) {
92
136
 
93
137
  /**
94
138
  * @param {import("@deepseek-ai/cordis").Context} ctx
139
+ * @param {object} [config] - parsed Config; volatile fields are live references.
95
140
  */
96
- function apply(ctx) {
141
+ function apply(ctx, config) {
97
142
  const logger = ctx.logger?.("rate-limiter");
98
143
 
99
- // Shared mutable config state. Starts with built-in defaults; once the
100
- // settings service is available the watcher replaces it with the live
101
- // user-configured value on every change.
102
- const defaultBase = {
103
- enabled: true,
104
- strategy: "token-bucket",
105
- defaults: { maxConcurrent: 5, maxRpm: 60, burstSize: 10, refillRate: 1 },
106
- models: {},
107
- onThrottled: "queue",
108
- maxQueueWaitMs: 60_000,
109
- };
110
- let cfg = { ...defaultBase };
144
+ /**
145
+ * Snapshot the whole config as one plain object, reading each reference at
146
+ * call time. Derived settings are not cached: a volatile-only change updates
147
+ * the references in place without re-running `apply`.
148
+ */
149
+ function readConfig() {
150
+ return {
151
+ enabled: read(config?.enabled, FALLBACK.enabled),
152
+ strategy: read(config?.strategy, FALLBACK.strategy),
153
+ defaults: read(config?.defaults, FALLBACK.defaults),
154
+ models: read(config?.models, FALLBACK.models),
155
+ onThrottled: read(config?.onThrottled, FALLBACK.onThrottled),
156
+ maxQueueWaitMs: read(config?.maxQueueWaitMs, FALLBACK.maxQueueWaitMs),
157
+ };
158
+ }
111
159
 
112
160
  /** @type {Map<string, TokenBucketStrategy | SlidingWindowStrategy>} */
113
161
  const limiters = new Map();
@@ -135,6 +183,7 @@ function apply(ctx) {
135
183
 
136
184
  /** Build the JSON-safe snapshot served on the "snapshot" endpoint. */
137
185
  function buildSnapshot() {
186
+ const cfg = readConfig();
138
187
  const models = {};
139
188
  for (const [key, limiter] of limiters) {
140
189
  try {
@@ -162,26 +211,18 @@ function apply(ctx) {
162
211
  rev += 1;
163
212
  }
164
213
 
165
- // ── Settings wiring (dynamic inject) ───────────────────────
166
- // Wrapped in ctx.inject so the plugin works on older DSH versions that
167
- // don't have a settings service at all — the callback simply never runs.
214
+ // ── Settings presentation (dynamic inject) ─────────────────
215
+ // This plugin ships its own browser page, so it opts out of any
216
+ // schema-generated page. Wrapped in ctx.inject so a host WITHOUT a settings
217
+ // service still runs the limiter — the callback simply never runs. The
218
+ // optional child ctx names the plugin fiber the policy belongs to, which is
219
+ // why `ctx.fiber` is passed explicitly (`dsh-settings/lib/index.js:370`).
168
220
  ctx.inject(["settings"], (scopedCtx) => {
169
- const scope = scopedCtx.settings.register(SETTINGS_NS, RateLimiterConfig, {
170
- base: defaultBase,
171
- });
172
-
173
- logger?.info("rate limiter settings namespace registered (%s)", SETTINGS_NS);
174
-
175
- // Replace cfg with live settings value on every change.
176
- cfg = scope.get();
177
- scope.watch((c) => { cfg = c; });
178
-
179
- // Dispose limiters on settings teardown.
180
- scopedCtx.effect(() => () => {
181
- logger?.info("rate limiter settings scope tearing down (%s)", SETTINGS_NS);
182
- for (const limiter of limiters.values()) limiter.dispose();
183
- limiters.clear();
184
- }, "rate-limiter: settings scope teardown");
221
+ if (typeof scopedCtx.settings?.configure !== "function") return; // older/foreign settings seam: keep running
222
+ scopedCtx.effect(
223
+ () => scopedCtx.settings.configure({ auto: false }, ctx.fiber),
224
+ "rate-limiter: settings presentation",
225
+ );
185
226
  });
186
227
 
187
228
  // ── Status channel (dynamic inject) ────────────────────────
@@ -207,6 +248,8 @@ function apply(ctx) {
207
248
  // 'cannot get property "webServer" without inject'. Registering the
208
249
  // route on our own fiber (which injects webServer below) avoids that
209
250
  // while still using the framework's own 403/401 fence.
251
+ // Still unfixed in 0.1.7-rc.1 (dsh-client-connection/lib/index.js:798
252
+ // keeps `inject = ["credentials"]`), so the fallback still fires.
210
253
  ctx.inject(["connection"], (scopedCtx) => {
211
254
  const connection = scopedCtx.get("connection");
212
255
  const channel = createStatusChannel({ buildSnapshot, resetTotals });
@@ -271,10 +314,11 @@ function apply(ctx) {
271
314
  });
272
315
 
273
316
  // ── LLM stream interceptor ─────────────────────────────────
274
- // ctx.on('llm/stream') doesn't need the settings service — it's a plain
275
- // waterfall listener that reads the shared `cfg` variable (initialised
276
- // with defaults; replaced by the settings watcher when available).
317
+ // Reads the live Config on every call, so a settings edit that reaches the
318
+ // loader takes effect on the next request without a restart.
277
319
  const disposeLlmListener = ctx.on("llm/stream", async function* rateLimitInterceptor(options, next) {
320
+ const cfg = readConfig();
321
+
278
322
  if (!cfg.enabled) {
279
323
  yield* next();
280
324
  return;
@@ -359,4 +403,4 @@ function apply(ctx) {
359
403
  logger?.info("rate limiter active — llm/stream interceptor registered");
360
404
  }
361
405
 
362
- export { apply, name, SETTINGS_NS };
406
+ export { apply, name, SETTINGS_NS, Config };