@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/CHANGELOG.md +189 -1
- package/README.md +48 -21
- package/README.zh.md +284 -0
- package/lib/client.js +182 -140
- package/lib/index.js +88 -44
- package/lib/types/config.js +48 -16
- package/lib/types/index.js +1 -1
- package/package.json +12 -12
- package/COMPATIBILITY.md +0 -221
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
|
|
6
|
-
*
|
|
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
|
-
|
|
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-
|
|
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
|
|
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:
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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(
|
|
360
|
+
const key = prompt("添加模型限速 (格式: provider/model)\n例如: deepseek/deepseek-chat");
|
|
326
361
|
if (!key) return;
|
|
327
|
-
|
|
362
|
+
setPath(["models", key], {});
|
|
328
363
|
}
|
|
329
364
|
function removeModel(key) {
|
|
330
|
-
|
|
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 === "")
|
|
335
|
-
else
|
|
369
|
+
if (value === undefined || value === "") unsetPath(path);
|
|
370
|
+
else setPath(path, value);
|
|
336
371
|
}
|
|
337
372
|
function setDefault(field, value) {
|
|
338
|
-
|
|
373
|
+
setPath(["defaults", field], value);
|
|
339
374
|
}
|
|
340
375
|
function setTop(field, value) {
|
|
341
|
-
|
|
376
|
+
setPath([field], value);
|
|
342
377
|
}
|
|
343
378
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
"
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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("
|
|
366
|
-
|
|
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
|
-
|
|
372
|
-
jsx("
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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?.
|
|
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?.
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
jsx("
|
|
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
|
-
|
|
433
|
-
jsx("input", { type: "
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
|
449
|
-
ctx.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
166
|
-
//
|
|
167
|
-
//
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
//
|
|
275
|
-
//
|
|
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 };
|