5 Commits

Author SHA1 Message Date
688dce88d1 refactor(rename)!: llmsproxy → modelrouter(module / 二进制 / 路径 / env)
产品名早已是 ModelRouter,但仓库标识符仍沿用旧名。这次统一,读写两侧都做兼容,
已部署的机器不会因为顺序问题立刻失效。

- go module `llmsproxy` → `modelrouter`;全仓 import 与 `cmd/modelrouter` 同步。
- `Makefile BINARY=modelrouter`;发行包名、systemd unit、/usr/share 资源、
  `/usr/local/bin` 目标全部改为 modelrouter。
- 默认配置目录 `/etc/llmsproxy` → `/etc/modelrouter`。**读取侧回退**:插件与
  `storeDir` 仍会检查旧路径,所以"先换二进制、后改 unit"不会立即失效。
- 环境变量 `LLMSPROXY_*`(PLUGIN_DIR / XDISPLAY_BASE / ARCH / LIVE_ZEN /
  BUILD_DIR)改为 `MODELROUTER_*`;`LLMS_PROXY_MASTER_KEY` →
  `MODELROUTER_MASTER_KEY`。旧名在读取侧继续接受。
- 桌面端内嵌核心 `bin/llmsproxy` → `bin/modelrouter`,并保留旧文件名回退,
  避免升级到一半的 profile 被判成"core binary missing"。
- 新增 `packaging/migrate-to-modelrouter.sh`:**默认 dry-run**,复制而非移动、
  旧目录永不删除、支持 `--rollback`;随 release tar 分发并由 core-dist 校验。
- `deploy.sh` 增加改名保护:发现旧 `/etc/llmsproxy` 而新配置不存在时**中止**,
  否则会部署出一个"能启动但没有 key、没有源"的空实例。
- 临时目录前缀 `/tmp/llmsproxy-*` → `/tmp/modelrouter-*`;清理逻辑同时回收两代。

兼容别名(`__llmsproxy_*` 全局、`llmsproxy` SDK 别名、旧路径/旧 env)刻意保留,
它们是升级路径而非遗留;docs/plugins.md 已标注为废弃。

本提交未包含:`adapters/opencodezen.lua`、`adapter_opencodezen_test.go`、
`adapter_opencodezen_live_test.go`(OpenCode UA/免费池的既有改动,另批提交)。
2026-10-26 22:28:08 +08:00
7a8d97001c feat(插件代理): 同源反向代理 —— VNC 不再暴露额外端口
问题:登录返回的 view_url 是 http://127.0.0.1:60100/vnc.html,WebUI 直接把它
嵌进 iframe。远程运维打开必白屏 —— 127.0.0.1 在客户端指向客户端自己,且
websockify/x11vnc 只绑回环,换服务器 IP 也连不上(实测 LAN IP 访问 000)。

★ 教训:只验证"服务端截图有画面"就宣布 VNC 可用是不充分的。四层链路里
①X 渲染 ②noVNC 客户端渲染 一直是好的,白屏真因在第④层「客户端如何拿到地址」。

内核新增通用「插件前缀代理」能力(不是为 channels 写的专用反向代理):
- plugin.http 支持 prefix = true:声明前缀路由,承接所有子路径
- handler 返回 { proxy = "http://127.0.0.1:PORT" },内核每次请求都问一遍
  ⇒ 会话停止/过期后插件不再给目标,旧地址自然失效(撤销点唯一)
- 鉴权与角色门禁复用现有链路,无旁路
- statusRecorder 增加 Unwrap(),让 Hijack 穿透访问日志中间件(否则 WebSocket 升级失败)
- 目标只允许带显式端口的**字面回环 IP**(用户拍板方案 A):拒绝 https、localhost
  主机名、无端口、带 userinfo/path/query;理由是主机名可能被 hosts/DNS 改写
- 拒绝跨站 Origin(WebSocket 带环境 Cookie,否则第三方网页可借管理员会话开隧道)
- 不向插件后端转发 Authorization/Cookie/api_key

channels 接入:
- 新增 channels/browser/proxy/:session(prefix=true)
- 会话代号 = seq-随机8位,防猜到活跃会话
- view_url 改为网关同源路径,noVNC 的 WebSocket 走同前缀 /websockify
- 会话过期即回收浏览器栈并返回 404

判据(每条咬真实外部效应):
- 静态资源转发
- 真实 HTTP 101 升级 + 双向数据回传(只断言静态转发不够 —— 升级是白屏真考点)
- 网关凭据不泄漏(在服务端断言请求头)
- 非法目标全拒(公网/localhost/https/无端口/带 path、query、userinfo)
- 跨站 Origin 403
- 未鉴权 401、过期会话 404、非子路径 404

回归:go test -tags luajit ./... 9 包全绿
2026-10-15 14:57:13 +08:00
5948ad19ca feat(plugins): 服务路径/适配器名冲突进裁决而非崩溃或硬拒绝(用户要求 A+B)
此前两类冲突各有问题:
A) 两个插件声明同一条 /api/plugins/http/ 服务路径 → 分发器按加载顺序静默
   选一个(装个新插件就可能改变既有路径归属)。
B) 插件装与内置/他插件同名的适配器 → 硬错误 ErrAdapterNameReserved / owned by。

用户要求:都改成「报冲突、列出候选、运维选一个保留、选择持久化」,绝不崩溃
或拒绝启动。

## A 类:插件 HTTP 服务路径冲突
- dispatch(plugin_http_routes.go)同路径多认领时不再 load-order 选边:未裁决
  返回 409 并点名候选;已裁决按赢家分发。
- route_conflicts.go:RouteConflicts/RouteWinner/ResolveRouteConflict,只统计
  loaded 且未禁用的插件;赢家插件若不再声明该路径,裁决作废回未裁决态。

## B 类:适配器名冲突
- InstallAdapter 撞名(内置或他插件所有)不再硬拒,改挂起:现任继续服务,候选
  入 AdapterConflicts,返回 typed ErrAdapterConflictPending(区分「代码错」)。
- ResolveAdapterConflict:incumbent 保留现任 / candidate 真正装上插件版(绕过
  reserved 门禁——这是运维显式授权覆盖内置的唯一入口)并转移归属。
- 现任内置永不被静默覆盖,守住「第三方插件不得篡改内核发行物」信任边界。

## 持久化 + WebAPI + WebUI
- 两张裁决表落 .route-conflicts.json(routes + adapters),LoadDir 时读回;
  重启后插件再装同名适配器自动套用上次裁决。
- WebAPI:GET /api/plugins/conflicts 列冲突;POST {kind,path|name,winner} 裁决。
- WebUI:插件页顶部「冲突裁决」卡片,逐条列路径/适配器冲突 + 候选 + 裁决按钮。

## 测试
- 单测 conflict_arbitration_test.go 6 项全过:路径冲突检测/裁决/持久化/重启、
  赢家消失回退、适配器撞名挂起/incumbent/candidate/归属转移、非法 winner 拒绝、
  自身更新不受影响(token 轮换不走冲突路径)。
- e2e conflict_arbitration_e2e_test.go 2 项全过(真二进制):两插件抢路径→409
  点名→裁决→200 赢家应答→/v1/models 无回归;插件装 shadow openai→挂起→现任
  保留→裁决 candidate→插件版生效→冲突清空。
- 全量 build + vet 通过,既有 lua/gateway/core 回归无破坏。

修正过程中发现并修了一个真 bug:LoadAdapterSource 对 key≠declared 的适配器
重装会建第二个池、留旧池继续服务(v2 装了 v1 还在跑)——改成统一按注册键
归档,裁决 candidate 后新版本才真正生效。
2026-10-12 12:32:10 +08:00
e4111337cd feat(gateway): /v1/messages 端到端 —— 内核不含任何 Anthropic 出站字段名
Phase 2 收尾。证明「对外协议由适配器决定、内核只调度」不是纸面主张:
Anthropic 客户端打 /v1/messages,拿到的是 Anthropic 形状。

- internal/gateway/messages.go:入向把 Anthropic 请求解析进统一格式
  (content blocks 原样保留——只有适配器知道自己的上游接受哪种块类型;
  system 支持字符串与 [{type:text,text}] 两种写法)。
  调度/配额/AUTO 链/冷却/审计/插件 hook 全部复用 /v1/chat/completions
  的同一条路径(serveUnified),差别只有两端线格式。
- internal/lua/adapters/anthropic.lua:新增 serialize_response /
  serialize_open_preamble / serialize_chunk / serialize_close,
  把统一格式译回 Anthropic Messages 形状(含 content 分块、
  stop_reason 反向映射、input/output_tokens 改名)。
- 流式是 message_start → content_block_delta → message_delta →
  message_stop;DoneMarker 为空所以内核**不会**擅自加 [DONE]。

内核出向零 Anthropic 字段名:解析是表驱动的,序列化属于适配器。

测试:
- messages_test.go 5 个端到端用例(真实 core+gateway + 只懂 Anthropic 的
  假上游):形状/stop_reason/块顺序/usage 命名、流式事件序列、入向校验、
  鉴权、以及「未声明钩子时回落 OpenAI 兜底」。
- 修正两个 serializer 测试的前提断言(anthropic 现在确实声明了钩子,
  这正是 Phase 2 的意图),并发测试改为按适配器各自校验形状。

golden 字节级未漂移;go test -tags luajit ./... 全绿。
2026-10-11 09:51:27 +08:00
35b9422232 feat(plugins): 可见性、state 分区、注册式 HTTP 端点 + 按密钥授权
插件页面不是自动公开的数据。billing 显示跨全部源的成本,quota 显示上游余额,
而这两者都不属于持有 user 密钥的人 —— 但 /api/ui-inject 不做任何角色过滤,
所以两者都发给了任何已认证的密钥。实测(不是推断):club 这把 user 密钥拉到了
18672 字节的 billing state,里面是 16871 次请求 / $8.8616 / 21 个源名与各自
成本。

UIPage/UIElement 加 Audience,UIExtension 加 Visibility。语法刻意很小——"" 或
"all" 放行,"admin" 只给 admin,"admin,user" 列角色。**"*" 不是 all 的同义词**:
一个在配置文件里看起来受限、实际放行一切的东西,正是会静默失效的那种设置。

覆盖走 config.yaml 的 plugin_visibility,因为插件作者不知道本部署的策略,而运维
也不该为了改一行去编辑内置 .lua(下次升级就没了)。

过滤发生在 UIForRole 而非 HTTP 层:WebUI 启动载荷、Electron 外壳、测试都读同一
处,只在 HTTP 层过滤意味着其它消费者照旧全量泄漏。

角色可见性回答不了"哪一部分"。billing 里全局数字(total、by_source、by_model、
未定价告警)是部署数据,by_key / by_day_key 描述的是**一个**调用方。于是插件用
state_scope 声明哪些桶是全局的、哪些按调用方过滤,深度由插件给出(1 = {maskedKey:
桶},2 = {day: {maskedKey: 桶}})——两种形状都是 map,靠嗅探分不清"空桶"和"嵌套桶"。

不匹配的行**删除而不是置零**:置零会渲染成"你没花钱",还会泄露"别人有这一行"。
billing 因此改为 visibility="all" + state_scope,而不是 admin-only —— 后者会让
split 失去意义,而前者不加 split 正是这次要堵的泄露。

有些东西值得从浏览器**外面**查(CI 判断上游余额是否为零、监控探针、盒子上的
另一个工具)。billing 从不需要(它只收 hook),quota 需要。为每个插件硬编码一条
路由意味着每次都要改网关,而把任意插件代码塞进主 mux 也不是路由表该做的事。

插件在 plugin.http 里声明 path/method/roles/keys,挂在 /api/plugins/http/ 下。
请求与响应都是普通 table 穿过 Go/Lua 边界。两条由 Go 侧而非信任插件来保证的性质:
status 与 method 走白名单(否则插件能选一个 3xx + Location 把调用者弹到它指定的
URL),Content-Length 与逐跳头被丢弃(否则插件能让网关的响应分帧错位)。

roles 太粗:无法表达"给 homeagent 开放、其他人不开"。keys 按**密钥名**授权而非密钥
值——密钥值会被轮换,规则写死在上面会静默失配;把凭据散进插件文件也是问题;而且
运维读的是名字。

**keys 非空时它是全部规则**,不是与 roles 的并集。写成并集的话,运维写两条允许名单
就会顺带放行所有 user 密钥,正好与意图相反。admin 永远放行,否则名单里一个拼写
错误会让唯一能诊断它的人也进不去。

写入前校验密钥名真实存在:一份写着错别字名字的名单会被存下、保存、在 WebUI 里
显示为"已配置",然后拒绝每一个调用者——失败看起来像端点坏了,而不是名字错了。

readPluginUI / readPluginExports / readPluginHTTP 都在插件**注册期间**调用
luaToJSON,而它依赖 setupGlobals 才安装的 json 全局,于是静默返回空值。三处表现各
不同(页面字段为空、exports 看不见、端点数为 0),同一个原因。现在统一走
luaTableToGoValue。

readPluginHTTP 还额外踩了 golua 的 GetField 不做字符串→数字转换,用索引循环取数组
永远拿到 nil。

去掉角色过滤 → 3 条判据红(含"a user key can see the admin-only page");
去掉 page 级 audience → 1 条红。routes 层断言徽标齐全、方法与 path 校验、栈帧
归属、admin 豁免、{status=204} 合法、空表报错。

生产验证:admin 看到 14 个字段 / by_key 5 行 / total $8.86;user 看到 0 行。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit a6c6e6e5c7)
(cherry picked from commit 5eef5ad635)
2026-10-04 14:50:46 +08:00