diff --git a/.gitignore b/.gitignore
index 131c027..0bfbbea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,7 @@ cmd/gui/dist/
# local design/working notes (not part of the shipped repo)
/plan.md
+
+# scratch build/probe dirs used while debugging (GOTMPDIR, throwaway binaries)
+.build-work/
+.probe/
diff --git a/cmd/gui/main.js b/cmd/gui/main.js
index ab33943..abc6ea9 100644
--- a/cmd/gui/main.js
+++ b/cmd/gui/main.js
@@ -671,6 +671,78 @@ ipcMain.handle(
(e) => !!BrowserWindow.fromWebContents(e.sender)?.isMaximized(),
);
+// ---- plugin management over IPC -------------------------------------------
+//
+// The renderer cannot call the embedded core directly: it has no key and no
+// network identity, and the core binds a loopback port that only the main
+// process knows about. So every plugin action is proxied through the main
+// process, which already knows how to obtain the admin key (unsealViaCore).
+//
+// The proxy is deliberately a raw (method, path, body) pass-through rather than
+// a fixed set of commands. A fixed set would have to be extended for every new
+// plugin endpoint, and the one thing worse than "no button for this" is "a
+// button that silently does nothing" — with a pass-through the renderer can talk
+// to any /api/plugins route the core grows, and the path is validated here so
+// this channel cannot be used to reach arbitrary endpoints.
+function pluginProxy(req) {
+ const { method, path, body } = req || {};
+ const M = ["GET", "POST", "PUT", "DELETE"];
+ if (!M.includes(method)) throw new Error("bad method: " + method);
+ // The path must stay inside the plugin namespace. A prefix check alone would
+ // still allow /api/plugins/../keys, so reject any traversal outright.
+ if (typeof path !== "string" || !path.startsWith("/api/plugins")) {
+ throw new Error("path must start with /api/plugins");
+ }
+ if (path.includes("..") || path.includes("\\")) {
+ throw new Error("path traversal rejected");
+ }
+ const key = unsealViaCore();
+ if (!key) throw new Error("no admin key available yet");
+ return new Promise((resolve, reject) => {
+ const u = new URL(embeddedBaseUrl() + path);
+ const data = body == null ? null : JSON.stringify(body);
+ const headers = { Authorization: "Bearer " + key };
+ if (data) {
+ headers["Content-Type"] = "application/json";
+ headers["Content-Length"] = Buffer.byteLength(data);
+ }
+ const r = http.request(
+ {
+ hostname: u.hostname,
+ port: u.port,
+ path: u.pathname + u.search,
+ method,
+ headers,
+ },
+ (res) => {
+ let raw = "";
+ res.setEncoding("utf8");
+ res.on("data", (c) => (raw += c));
+ res.on("end", () => {
+ let parsed = null;
+ try {
+ parsed = raw ? JSON.parse(raw) : null;
+ } catch (e) {
+ parsed = { raw };
+ }
+ if (res.statusCode >= 400) {
+ const msg =
+ (parsed && parsed.error && parsed.error.message) ||
+ "HTTP " + res.statusCode;
+ reject(new Error(msg));
+ return;
+ }
+ resolve(parsed);
+ });
+ },
+ );
+ r.on("error", reject);
+ if (data) r.write(data);
+ r.end();
+ });
+}
+
+ipcMain.handle("plugins:proxy", (_e, req) => pluginProxy(req));
ipcMain.handle("core:state", () => ({
running: coreStarted() && coreReady,
ready: coreReady,
diff --git a/cmd/gui/package-lock.json b/cmd/gui/package-lock.json
index 96dd73e..21f79ad 100644
--- a/cmd/gui/package-lock.json
+++ b/cmd/gui/package-lock.json
@@ -582,7 +582,7 @@
}
},
"node_modules/@peculiar/webcrypto": {
- "version": "1.7.1",
+ "version": "1.7.5",
"resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz",
"integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==",
"dev": true,
@@ -3335,7 +3335,7 @@
}
},
"node_modules/resedit": {
- "version": "1.7.2",
+ "version": "1.7.5",
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
"integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==",
"dev": true,
diff --git a/cmd/gui/preload.js b/cmd/gui/preload.js
index c3cfca8..f9c46e4 100644
--- a/cmd/gui/preload.js
+++ b/cmd/gui/preload.js
@@ -16,6 +16,13 @@ contextBridge.exposeInMainWorld("modelrouter", {
key: () => ipcRenderer.invoke("core:key"),
onState: (cb) => ipcRenderer.on("core:state", (_e, d) => cb(d)),
},
+ plugins: {
+ // Raw pass-through to the embedded core's /api/plugins surface. The main
+ // process validates the path and attaches the admin key; the renderer never
+ // sees either.
+ request: (method, path, body) =>
+ ipcRenderer.invoke("plugins:proxy", { method, path, body }),
+ },
settings: {
get: () => ipcRenderer.invoke("settings:get"),
set: (patch) => ipcRenderer.invoke("settings:set", patch),
diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js
index 2d8d2ce..553a30b 100644
--- a/cmd/gui/renderer/app.js
+++ b/cmd/gui/renderer/app.js
@@ -155,6 +155,7 @@ async function openSettings() {
$("#set-tray").checked = !!state.settings.minimizeToTray;
$("#settings-overlay").style.display = "flex";
renderRail();
+ loadPlugins();
}
function closeSettings() {
$("#settings-overlay").style.display = "none";
@@ -182,6 +183,122 @@ async function saveSettings() {
}
}
+// esc / escAttr escape text for innerHTML. The WebUI has its own copies; the
+// shell needs its own because renderer/app.js is a separate document that
+// never loads index.html's script.
+function esc(s) {
+ return String(s == null ? "" : s).replace(
+ /[&<>"']/g,
+ (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
+ );
+}
+function escAttr(s) {
+ return esc(s).replace(/`/g, "`");
+}
+
+// ===== plugin management =====
+//
+// The desktop shell manages plugins through the embedded core's /api/plugins
+// surface, proxied over IPC (see plugins:proxy in the main process). The
+// renderer never holds the admin key.
+//
+// Scope note: the desktop build has no plugin_dir configured by default, so this
+// panel normally reports "plugins disabled" with the one-line fix. That is a
+// deliberate state, not an error — the packaged profile is a per-user directory
+// and seeding a plugin tree into someone's home without asking would be rude.
+
+async function loadPlugins() {
+ const list = $("#pl-list");
+ const hint = $("#set-plugins-hint");
+ if (!list || !hint) return;
+ let j;
+ try {
+ j = await window.modelrouter.plugins.request("GET", "/api/plugins");
+ } catch (e) {
+ hint.textContent = "内核未就绪:" + e.message;
+ list.innerHTML = "";
+ return;
+ }
+ if (!j.plugin_dir) {
+ hint.innerHTML =
+ '未配置 plugin_dir,插件功能未启用。在 config.yaml 加一行后重启内核即可。';
+ list.innerHTML = "";
+ return;
+ }
+ const rows = j.on_disk || [];
+ const active = rows.filter((p) => p.loaded && !p.disabled).length;
+ const broken = rows.filter((p) => !p.loaded).length;
+ hint.textContent =
+ `${rows.length} 个插件 · ${active} 个启用中` +
+ (broken ? ` · ${broken} 个加载失败` : "");
+ list.innerHTML = rows.length
+ ? rows
+ .map((p) => {
+ const cls = !p.loaded ? "pl-broken" : p.disabled ? "pl-off" : "pl-on";
+ const label = !p.loaded
+ ? "加载失败"
+ : p.disabled
+ ? "已禁用"
+ : "启用中";
+ const btn = p.loaded
+ ? ``
+ : "";
+ const builtin = p.builtin
+ ? '内置'
+ : "";
+ return `
+
${esc(p.name)}${builtin}${label}
+ ${p.description ? `
${esc(p.description)}
` : ""}
+ ${p.error ? `
${esc(String(p.error).slice(0, 160))}
` : ""}
+
${btn}
+
`;
+ })
+ .join("")
+ : '插件目录为空
';
+ list.querySelectorAll('button[data-act="toggle"]').forEach((b) => {
+ b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1");
+ });
+}
+
+async function togglePlugin(name, disabled) {
+ try {
+ await window.modelrouter.plugins.request("PUT", `/api/plugins/${encodeURIComponent(name)}`, {
+ enabled: disabled,
+ });
+ toast(disabled ? `已禁用 ${name}` : `已启用 ${name}`);
+ await loadPlugins();
+ } catch (e) {
+ toast(e.message, true);
+ }
+}
+
+async function enableAllPlugins() {
+ let j;
+ try {
+ j = await window.modelrouter.plugins.request("GET", "/api/plugins");
+ } catch (e) {
+ return toast(e.message, true);
+ }
+ const off = (j.on_disk || []).filter((p) => p.loaded && p.disabled);
+ for (const p of off) {
+ try {
+ await window.modelrouter.plugins.request(
+ "PUT",
+ `/api/plugins/${encodeURIComponent(p.name)}`,
+ { enabled: true },
+ );
+ } catch (e) {
+ toast(`${p.name}: ${e.message}`, true);
+ }
+ }
+ toast(off.length ? `已启用 ${off.length} 个插件` : "没有处于禁用状态的插件");
+ await loadPlugins();
+}
+
// ===== theme =====
function applyTheme() {
document.documentElement.dataset.theme = state.theme;
@@ -197,6 +314,10 @@ function init() {
$("#tb-close").onclick = () => window.modelrouter.win.close();
$("#tb-settings").onclick = openSettings;
$("#rail-settings").onclick = openSettings;
+ const plReload = document.getElementById("pl-reload");
+ if (plReload) plReload.onclick = loadPlugins;
+ const plAll = document.getElementById("pl-toggle-all");
+ if (plAll) plAll.onclick = enableAllPlugins;
$("#rail-autostart").onclick = toggleAutoStart;
$("#rail-silent").onclick = toggleSilent;
$("#rail-theme").onclick = () => {
diff --git a/cmd/gui/renderer/index.html b/cmd/gui/renderer/index.html
index 5e16ec6..86634c0 100644
--- a/cmd/gui/renderer/index.html
+++ b/cmd/gui/renderer/index.html
@@ -207,6 +207,15 @@
> 关闭时最小化到托盘点关闭按钮隐藏到系统托盘
+
+
+ 加载中…
+
+
+
+
+
+
diff --git a/cmd/gui/renderer/style.css b/cmd/gui/renderer/style.css
index b3661aa..05dd59c 100644
--- a/cmd/gui/renderer/style.css
+++ b/cmd/gui/renderer/style.css
@@ -596,3 +596,81 @@ html[data-theme="dark"] .overlay {
#toast.err {
border-color: var(--danger);
}
+
+/* ===== plugin management panel =========================================
+ * The existing .ghost/.primary rules are scoped to `.form .actions`, so a
+ * button outside that selector gets browser defaults. The plugin rows live in
+ * their own list, hence their own rules — reusing a scoped class here would have
+ * produced unstyled buttons that still worked, which is the kind of thing that
+ * looks fine until someone themes the shell.
+ */
+.pl-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin: 8px 0 4px;
+}
+.pl-item {
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ padding: 10px 12px;
+}
+.pl-item.pl-broken {
+ border-color: var(--danger, #d1435b);
+}
+.pl-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+}
+.pl-builtin {
+ font-size: 10px;
+ padding: 1px 6px;
+ border-radius: 999px;
+ background: var(--primary-50);
+ color: var(--primary-h);
+}
+.pl-state {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--muted);
+}
+.pl-item.pl-broken .pl-state {
+ color: var(--danger, #d1435b);
+}
+.pl-desc {
+ font-size: 12px;
+ color: var(--muted);
+ margin-top: 3px;
+}
+.pl-err {
+ font-size: 11px;
+ color: var(--danger, #d1435b);
+ margin-top: 4px;
+ word-break: break-word;
+}
+.pl-acts {
+ margin-top: 8px;
+ display: flex;
+ gap: 8px;
+}
+.pl-acts button {
+ padding: 5px 12px;
+ font-size: 12px;
+ border-radius: 7px;
+ border: 1px solid var(--line);
+ background: var(--bg-s2, #fff);
+ color: var(--fg, inherit);
+ cursor: pointer;
+ transition: all 0.15s;
+}
+.pl-acts button:hover {
+ border-color: var(--primary);
+ color: var(--primary-h);
+}
+.pl-empty {
+ font-size: 12px;
+ color: var(--muted);
+ padding: 10px 0;
+}
diff --git a/deploy.sh b/deploy.sh
index a4369ad..27712fe 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -301,6 +301,60 @@ verify_master_key() {
return 1
}
+# ---------- 适配器备份保留策略 ----------
+# 每次部署都新建一个 adapters.bak.<时间戳> 目录,而回滚只用到最近一次
+# ($BACKUP_BIN / $BACKUP_CONFIG 都是单文件覆盖)。多出来的目录从没人清理,
+# 实测线上已积累 71 个,/etc/llmsproxy 因此涨到 122M。
+#
+# 保留最近 KEEP_ADAPTER_BACKUPS 份足够回滚,同时给目录数设上限——否则
+# 一次误配置(比如 adapter_dir 指错)就可能在几秒内造出成百上千个目录。
+# 只删名字严格匹配 adapters.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
+KEEP_ADAPTER_BACKUPS=5
+
+prune_adapter_backups() {
+ local base="/etc/llmsproxy"
+ [[ -d "$base" ]] || return 0
+
+ # 先按数量上限硬裁:即使时间戳排序失效也不会无上限增长。
+ local all
+ mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort)
+ local cap=$((KEEP_ADAPTER_BACKUPS * 4))
+ if (( ${#all[@]} > cap )); then
+ warn "适配器备份目录有 ${#all[@]} 个(异常),裁到 $cap"
+ local i=0
+ for d in "${all[@]}"; do
+ i=$((i + 1))
+ # 从最旧的开始删(sort 后升序)。名字不规范的跳过不删。
+ if (( i <= ${#all[@]} - cap )); then
+ if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; then
+ rm -rf "${base:?}/$d"
+ else
+ warn "跳过名字不规范的备份目录(不删): $d"
+ fi
+ fi
+ done
+ mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort)
+ fi
+
+ # 再按时间保留最近 KEEP_ADAPTER_BACKUPS 份(sort 后最新在末尾)。
+ local total=${#all[@]} i=0
+ for d in "${all[@]}"; do
+ i=$((i + 1))
+ # i <= total-KEEP 的是较旧的,要删。
+ if (( i <= total - KEEP_ADAPTER_BACKUPS )); then
+ if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; then
+ rm -rf "${base:?}/$d"
+ else
+ warn "跳过名字不规范的备份目录(不删): $d"
+ fi
+ fi
+ done
+
+ local left
+ left=$(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' | wc -l)
+ log " 适配器备份保留最近 $KEEP_ADAPTER_BACKUPS 份(当前剩 $left 个)"
+}
+
# ---------- 同步适配器 ----------
sync_adapters() {
log "同步适配器"
@@ -318,6 +372,7 @@ sync_adapters() {
cp -rf "$TARGET_ADAPTERS/"*.lua "$BACKUP_DIR/" 2>/dev/null || true
log " 旧适配器已备份到 $BACKUP_DIR"
fi
+ prune_adapter_backups
cp -f "$SRC_ADAPTERS/"*.lua "$TARGET_ADAPTERS/"
chmod 0644 "$TARGET_ADAPTERS/"*.lua
diff --git a/docs/git-workflow-en.md b/docs/git-workflow-en.md
index 8c3064b..0ffea85 100644
--- a/docs/git-workflow-en.md
+++ b/docs/git-workflow-en.md
@@ -15,7 +15,7 @@ deployable.
|---|---|---|---|
| Main | `main` | permanent | Only long-lived branch. Always deployable. Accumulates the next version. |
| Feature | `feature/
` | short (dev → merge → delete) | New features / ordinary fixes. Born from `main`, merged back into `main`. |
-| Release | `release/vX.Y.Z` | one version cycle | Cut from `main`, tagged for release. Version-specific hotfixes land here. |
+| Release | `release/vX.Y.x` | one version cycle | Cut from `main`, tagged for release. Version-specific hotfixes land here. |
## Change flow (important)
@@ -26,15 +26,21 @@ deployable.
│ │
│ cut │ cut
▼ ▼
- release/v1.4.2 release/v1.4.3
+ release/v1.6.x release/v1.7.x
│ │
- tag: v1.4.2 tag: v1.4.3
+ tag: v1.6.0 tag: v1.7.6
│ │
hotfix ◄─────┘ hotfix ◄─────┘
│ │
└── cherry-pick back ──────────┘
```
+The patch position in the branch name is a literal `x`, while the tag carries
+the concrete version: one `release/v1.7.x` can hold tags v1.7.0 … v1.7.6.
+Spanning several patches on a single minor branch is deliberate — patches are
+revisions of the same feature batch, hotfixes land on one branch, and back-port
+to main never has to resolve dependencies between several release branches.
+
### Key rules
1. **main is always deployable**: never leave half-done work on `main`.
@@ -43,9 +49,9 @@ deployable.
then `git merge --no-ff feature/xxx` (or squash) when done.
3. **Release = cut a release branch from main + tag**:
```bash
- git checkout -b release/v1.4.2 main
- git tag -a v1.4.2 -m "ModelRouter v1.4.2"
- git push origin release/v1.4.2 v1.4.2
+ git checkout -b release/v1.7.x main
+ git tag -a v1.7.0 -m "ModelRouter v1.7.0"
+ git push origin release/v1.7.x v1.7.0
```
Build installers and upload the GitCode Release from this tag so the
published state is exactly reproducible.
@@ -54,7 +60,7 @@ deployable.
an already-released branch (unless you deliberately ship a minor revision).
5. **Hotfixes MUST flow back to main**:
```bash
- git checkout release/v1.4.2 # fix in the release branch
+ git checkout release/v1.7.x # fix in the release branch
git commit -m "fix: ..."
git checkout main
git cherry-pick # and into main
@@ -67,12 +73,23 @@ deployable.
When the next version ships, the previous release branch retires:
- **Default: delete the remote release branch**
- (`git push origin :release/v1.4.2`). All hotfixes were already
+ (`git push origin :release/v1.7.x`). All hotfixes were already
cherry-picked into main, so main contains everything; no merge needed.
- **Long-term maintenance** (e.g. an enterprise client pinned to an old
version): keep the branch, accept only security fixes, keep the
commit-then-cherry-pick loop.
+> **Where practice diverged from this section (checked 2026-10-01)**:
+> `release/v1.4.x` and `release/v1.5.x` still exist locally and on the remote,
+> so "retire the previous branch when the next version ships" was never
+> carried out. Keeping them is harmless (hotfixes were back-ported), but it
+> contradicts the rule above and makes a reader wonder whether they should be
+> there at all. **Feature branches, by contrast, are cleaned up**:
+> `feature/key-quota-control`, `feature/toolcall-id-sanitize`,
+> `feature/anthropic-usage-cache` and `feature/agentrouter-id-sanitize` were
+> deleted on 2026-10-01 after confirming with a per-commit `git patch-id`
+> comparison that their work had already landed in main.
+
## Explicit non-goals
- **Never rebase main**: main's history stays append-only; anyone pulling gets
@@ -134,8 +151,8 @@ pain points:
2. No feature branches meant two independent efforts could not proceed in
parallel without colliding.
-With release branches: the published state = `release/vX.Y.Z` branch +
-`vX.Y.Z` tag, exactly reproducible; hotfixes have a clear landing spot; main
-stays "latest + all fixes + deployable".
+With release branches: the published state = `release/vX.Y.x` branch +
+the concrete `vX.Y.Z` tag, exactly reproducible; hotfixes have a clear landing
+spot; main stays "latest + all fixes + deployable".
> 中文版见 [docs/git-workflow.md](git-workflow.md)。
\ No newline at end of file
diff --git a/docs/git-workflow.md b/docs/git-workflow.md
index 363d5a7..af8722b 100644
--- a/docs/git-workflow.md
+++ b/docs/git-workflow.md
@@ -13,7 +13,7 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长
|---|---|---|---|
| 主分支 | `main` | 永久 | 唯一长命分支。永远可部署。积攒下一个版本的功能。 |
| 特性分支 | `feature/<描述>` | 短命(开发→合并即删) | 新特性 / 一般 bug 修复。从 `main` 开出,完成后合回 `main`。 |
-| 发布分支 | `release/vX.Y.Z` | 一个版本周期 | 从 `main` 分出,打 tag 发布。该版本生命周期内的 hotfix 都提交在此分支。 |
+| 发布分支 | `release/vX.Y.x` | 一个版本周期 | 从 `main` 分出,打 tag 发布。该版本生命周期内的 hotfix 都提交在此分支。 |
## 变更流向(重要)
@@ -24,15 +24,20 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长
│ │
│ 切出 │ 切出
▼ ▼
- release/v1.4.2 release/v1.4.3
+ release/v1.6.x release/v1.7.x
│ │
- tag: v1.4.2 tag: v1.4.3
+ tag: v1.6.0 tag: v1.7.6
│ │
hotfix ◄─────┘ hotfix ◄─────┘
│ │
└── cherry-pick 回 main ───────┘
```
+分支名里 patch 位是**字面的 x**,而 tag 打具体版本号:`release/v1.7.x` 这一条
+发布分支上的 tag 可以有 v1.7.0 … v1.7.6 多个。一条 minor 分支跨多个 patch 是
+刻意的:patch 是同一批功能的不同修订,hotfix 落在同一条分支上,回流 main 时
+也不必处理多条 release 分支之间的依赖。
+
### 关键规则
1. **main 永远可部署**:不在 main 上留半成品。任何未完成的工作必须在特性分支上。
@@ -40,16 +45,16 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长
开发完 `git merge --no-ff feature/xxx` 或 squash 合回。
3. **发布 = 从 main 切 release 分支 + 打 tag**:
```bash
- git checkout -b release/v1.4.2 main
- git tag -a v1.4.2 -m "ModelRouter v1.4.2"
- git push origin release/v1.4.2 v1.4.2
+ git checkout -b release/v1.7.x main
+ git tag -a v1.7.0 -m "ModelRouter v1.7.0"
+ git push origin release/v1.7.x v1.7.0
```
构建安装包、上传 GitCode Release 都基于这个 tag,保证可精确回溯发布态。
4. **版本生命周期内只收该版本的 hotfix**:新特性一律并入 `main` 等下一个版本,
绝不塞进已发布的 release 分支(除非主动选择在该版本内发次要版)。
5. **hotfix 必须回流 main**:
```bash
- git checkout release/v1.4.2 # 在发布分支提交修复
+ git checkout release/v1.7.x # 在发布分支提交修复
git commit -m "fix: ..."
git checkout main
git cherry-pick # 回主分支
@@ -61,11 +66,20 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长
下一个版本发布时,上一个 release 分支退役:
-- **默认:直接删除远端 release 分支**(`git push origin :release/v1.4.2`)。
+- **默认:直接删除远端 release 分支**(`git push origin :release/v1.7.x`)。
因为 hotfix 都已逐个 cherry-pick 回 main,main 已包含全部修复,无需再合并。
- **如需要长期维护旧版**(例如企业大客户卡在旧版本):保留分支,仅 stopship 接受
该版本的安全修复,继续走「提交 + cherry-pick 回 main」循环。
+> **实践与本节的历史出入(2026-10-01 核对)**:`release/v1.4.x` 与 `release/v1.5.x`
+> 至今仍在本地与远端,说明"下一个版本发布就删上一个分支"实际没有执行。
+> 保留无害(hotfix 已回流),但它与上面写的规则不一致,读文档的人会以为
+> 这些分支不该存在。**特性分支则确实在清理**:`feature/key-quota-control`、
+> `feature/toolcall-id-sanitize`、`feature/anthropic-usage-cache`、
+> `feature/agentrouter-id-sanitize` 四个分支在 2026-10-01 删除——它们的工作
+> 早已全部进入 main(逐提交用 `git patch-id` 比对确认),留着只是给下个版本
+> 制造 cherry-pick/merge 陷阱。
+
## 明确不做的事
- **不 rebase main**:`main` 的历史保持追加式,任何人拉取后 `git pull` 都得到直接可用的历史。
diff --git a/docs/plugins.md b/docs/plugins.md
new file mode 100644
index 0000000..a759602
--- /dev/null
+++ b/docs/plugins.md
@@ -0,0 +1,507 @@
+# 插件系统(Plugin System)
+
+> **English**: this document is the reference for writing ModelRouter plugins.
+> The Chinese version is the primary one; section titles map 1:1.
+
+ModelRouter 的插件是**单个 `.lua` 文件**,放在 `config.yaml` 的 `plugin_dir` 目录里。
+插件能做两件事:
+
+1. **挂钩子**:在请求流水线的若干 stage 上注册回调,看到每个请求的完整信息,
+ 并可以把结果累加进自己的状态。
+2. **贡献界面**:在启动时返回 HTML / CSS / JS,由内核注入 WebUI——可以是一整个
+ 新页面,也可以是往现有页面里追加一个组件。
+
+两者互相独立:只想统计请求数的插件不必碰界面;只想加个仪表盘的插件不必碰钩子。
+
+---
+
+## 1. 快速上手
+
+一个最小的插件:
+
+```lua
+-- plugins/hello.lua
+local plugin = {
+ name = "hello",
+ version = "1.0.0",
+ description = "示例插件",
+ author = "you",
+}
+
+-- 声明钩子
+plugin.hooks = {
+ request_end = "on_request_end",
+}
+
+-- 钩子实现
+function plugin.on_request_end(payload)
+ -- payload 是解码后的 table,不是 JSON 字符串
+ log("info", string.format("%s via %s: %d prompt tokens",
+ payload.model, payload.source, payload.prompt_tokens or 0))
+ return nil -- 最后一个 stage 没有下游,return 无意义
+end
+
+-- 贡献界面
+plugin.ui = {
+ page = {
+ page_id = "hello", -- kebab-case
+ title = "Hello",
+ icon = "👋",
+ order = 90, -- 侧栏排序
+ mount = [[hello
]],
+ },
+}
+
+return plugin -- 必须返回一个 table
+```
+
+放进 `plugin_dir` 后重启即生效。`GET /api/plugins` 确认它被加载了。
+
+---
+
+## 2. 加载与生命周期
+
+```
+core.New
+ └─ lua.NewVM(adapter_dir).Start() 适配器状态
+ └─ lua.NewPlugins(vm, plugin_dir)
+ ├─ SeedBundled() 仅当目录不存在时写入内置插件(目前是 billing)
+ └─ LoadDir() 按文件名字典序逐个加载
+```
+
+**加载失败不影响网关启动。** 一个语法错误的插件会被记录在 `GET /api/plugins`
+的 `error` 字段里,永远不会被调用。这与适配器一致,但理由更强:插件是可选的
+第三方扩展,因为一个 `.lua` 打错字就让网关起不来是错误的取舍。
+
+**目录一旦存在就是权威的。** 与适配器同规则:首启会 seed 内置插件,之后目录里
+的文件说了算,删除或编辑内置插件都是真实生效的操作。
+
+### 2.1 热更新
+
+| 方式 | 效果 |
+|---|---|
+| `POST /api/plugins {name, code}` | 写文件 + 立即加载新版本(旧的 Lua 状态被关闭重建,**累计量清零**) |
+| `DELETE /api/plugins/{name}` | 删文件 + 卸载 |
+| 改文件后 `POST` 同名 | 同上 |
+
+改文件但**不** POST,需要重启才生效。
+
+---
+
+## 3. 流水线 stage
+
+一个请求依次经过三个 stage。插件可以为任意 stage 注册钩子;未注册的 stage
+被忽略,所以插件不会因为网关将来新增 stage 而报错。
+
+```
+ 客户端请求
+ │
+ ┌─────────▼──────────┐
+ │ request_start │ 已解析、已鉴权,尚未选源
+ │ · type │ "chat" | "stream" | "image"
+ │ · model │ 客户端请求的原始 model("AUTO" 也在这里)
+ │ · key / role │ 掩码后的网关 key id("***a1b2c3")与角色
+ │ · source │ 空(还没选源)
+ │ · stream │
+ │ · messages_count │
+ │ · tools_count │
+ │ · ts │ unix 秒
+ └─────────┬──────────┘
+ │ (调度:tier 遍历 → 槽位轮转 → 冷却/配额过滤)
+ ┌─────────▼──────────┐
+ │ routed │ 已选定 (source, model),尚未发往上游
+ │ · source / model │ 实际选中的
+ │ · tier │ AUTO 链的档位;直连 = -1;AUTO = -2
+ │ · stream / key │
+ └─────────┬──────────┘
+ │ (HTTP 往返 / SSE 流)
+ ┌─────────▼──────────┐
+ │ request_end │ 每个请求恰好一次,成功失败都触发
+ │ · ok / status │
+ │ · latency_ms │
+ │ · first_byte_ms │ 流式的首字节时间
+ │ · prompt_tokens │ 上游真实 usage,缺失时为字节估算
+ │ · completion_tokens
+ │ · cache_hit_tokens / cache_miss_tokens
+ │ · image_count │ 生图数量(图片不计 token)
+ │ · error │ 失败原因,成功时为 ""
+ │ · time │ unix **毫秒**
+ └─────────┬──────────┘
+ │
+ 写审计 + 聚合统计
+```
+
+### 3.1 触发点在哪
+
+| stage | 代码位置 | 说明 |
+|---|---|---|
+| `request_start` | `gateway/chat.go` `handleChat` / `fireImageStart` | 每个请求一次(chat 与生图各一条),在配额闸门**之前** |
+| `chain_step` | `gateway/chat.go` `chainTraceSink` | **仅 AUTO 路径**,每步一次 |
+| `routed` | `singleChat` / `streamChat` / `singleChatAuto` / `streamChatAuto` | 成功选定源之后,各一次 |
+| `request_end` | `gateway/chat.go` `writeRec` | **所有出口的唯一汇合点**,每个请求一次 |
+
+### 3.1b 为什么单独有 `chain_step`
+
+`routed` 只在**遍历结束后**触发一次,只带最终胜出的槽位。所以
+"tier 1 冷却所以降级到 tier 3"和"tier 1 正常接单"在它眼里**完全一样**——
+而这恰恰是优先级链存在的全部理由。
+
+`chain_step` 补上这条信息,四种 `kind`:
+
+| kind | 含义 | 何时产生 |
+|---|---|---|
+| `tier_skip` | 整档被跳过 | 该档所有槽位冷却中/配额用尽 |
+| `slot_fail` | 某个槽位硬失败 | 上游报错 / 适配器输出不可用 |
+| `tier_busy` | 整档全忙且有界等待超时 | 2s 内没等到空位 |
+| `selected` | 这个槽位接了单 | 每次成功遍历**恰好一次**,且是最后一步 |
+
+顺序保证:所有 `chain_step` 都在 `routed` 之前,`selected` 是最后一步。
+所以只订阅 `request_end` 的插件也能拿到轨迹摘要(见下)。
+
+### 3.1c `request_end` 里的轨迹摘要
+
+除了逐个 `chain_step`,`request_end` 还带三个便于做报表的字段:
+
+| 字段 | 含义 |
+|---|---|
+| `chain_walk` | 整个遍历的步骤数组(上限 12 步,超出截断) |
+| `degraded` | 布尔。`true` = 有过跳过/失败,即**发生了降级** |
+| `tier_served` | 实际服务的那一档;直连或全失败时为 `-1` |
+
+> **计费口径**:按**实际服务的模型**计费。降级到 tier 3 仍按 tier 3 的价算,
+> `chain_step` / `degraded` / `tier_served` 只作**观测**,不参与计价。
+> 理由见 §7.5。
+
+`request_end` 放在 `writeRec` 是因为四条入口路径(直连/AUTO × 流式/非流式)都
+经过它,既不会漏(流式的 token 数只有流结束才知道),也不会重复。
+
+**hot path 注意事项**:没有插件注册某 stage 时,`Fire` 立刻返回(一次 `RLock`
+加一次 map 查找)。装了插件之后,每个请求会在该 stage 上多一次 Lua 调用——
+这是同步的,在关键路径上。计费插件那种"每请求一次"是正常的;把重活放进钩子是
+反模式。
+
+### 3.2 钩子的返回值
+
+- 返回 `nil` 或不返回 = **没有意见**,payload 原样传给下一个插件
+- 返回 table = 其中的键会**合并进 payload**,并作为 `Fire` 的返回值
+
+**同一 stage 的多个插件并行执行**,但返回值按**插件加载顺序**合并,所以结果是
+确定的(不依赖 goroutine 调度)。代价是一个插件看不到另一个插件刚加的字段:
+每个钩子拿到的是**同一份 payload 快照**。
+
+这与早期版本不同 —— 早期是顺序执行,后一个插件能看到前一个的返回值。它从未被
+实际依赖(随核心发布的 billing 在每个 stage 都 `return nil`,注释里写着
+"nobody downstream would read a return value"),但这是一处**契约变化**:如果你的
+插件依赖「读到前一个插件写的字段」,并行的两个插件之间必须改用外部通信
+(例如各自写 `plugin.state`,由 `/api/plugins//state` 读取)。
+
+单个插件时不启 goroutine,直接调用。
+
+前三个 stage 的返回值目前没有内部消费者(最后一个 stage 之后就是写审计),
+所以计费插件改用 `plugin.state` + `/state` 端点来暴露数据。
+
+---
+
+## 4. 界面扩展
+
+### 4.1 整页
+
+```lua
+plugin.ui = {
+ page = {
+ page_id = "billing", -- 必填,kebab-case。侧栏 data-tab 与 #tab-billing
+ title = "Billing", -- 必填,侧栏文字
+ icon = "💰", -- 可选
+ order = 40, -- 侧栏排序,默认 100
+ mount = [[...HTML...]],
+ },
+}
+```
+
+### 4.2 往现有页面追加元素
+
+```lua
+plugin.ui = {
+ elements = {
+ {
+ target = "status", -- status | chat | keys | sort | sources | adapters
+ anchor = "top", -- "top" | "bottom" | "before:" | "after:"
+ order = 5,
+ mount = [[...HTML...]],
+ },
+ },
+}
+```
+
+### 4.3 `mount` 里可以带 `